// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; using AWS.Deploy.Common; using AWS.Deploy.Common.IO; using AWS.Deploy.Common.Recipes; namespace AWS.Deploy.CLI { public enum YesNo { Yes = 1, No = 0 } public interface IConsoleUtilities { SortedSet AskUserForList(UserInputConfiguration userInputConfiguration, List availableData, OptionSettingItem optionSetting, Recommendation recommendation); Recommendation AskToChooseRecommendation(IList recommendations); string AskUserToChoose(IList values, string title, string? defaultValue, string? defaultChoosePrompt = null); T AskUserToChoose(IList options, string title, T defaultValue, string? defaultChoosePrompt = null) where T : IUserInputOption; void DisplayRow((string, int)[] row); UserResponse AskUserToChooseOrCreateNew(IEnumerable options, string title, bool askNewName = true, string defaultNewName = "", bool canBeEmpty = false, string? defaultChoosePrompt = null, string? defaultCreateNewPrompt = null, string? defaultCreateNewLabel = null); UserResponse AskUserToChooseOrCreateNew(IEnumerable options, string title, UserInputConfiguration userInputConfiguration, string? defaultChoosePrompt = null, string? defaultCreateNewPrompt = null, string? defaultCreateNewLabel = null); string AskUserForValue(string message, string defaultValue, bool allowEmpty, string resetValue = "", string? defaultAskValuePrompt = null, params Func>[] validators); string AskForEC2KeyPairSaveDirectory(string projectPath); YesNo AskYesNoQuestion(string question, string? defaultValue); YesNo AskYesNoQuestion(string question, YesNo? defaultValue = default); void DisplayValues(Dictionary objectValues, string indent); Dictionary AskUserForKeyValue(Dictionary keyValue); SortedSet AskUserForList(SortedSet listValue); } public class ConsoleUtilities : IConsoleUtilities { private readonly IToolInteractiveService _interactiveService; private readonly IDirectoryManager _directoryManager; private readonly IOptionSettingHandler _optionSettingHandler; public ConsoleUtilities(IToolInteractiveService interactiveService, IDirectoryManager directoryManager, IOptionSettingHandler optionSettingHandler) { _interactiveService = interactiveService; _directoryManager = directoryManager; _optionSettingHandler = optionSettingHandler; } /// /// This method is used to display a list of values and allow the user to select multiple values. /// The user will have the ability to add and delete values from the list. /// public SortedSet AskUserForList( UserInputConfiguration userInputConfiguration, List availableData, OptionSettingItem optionSetting, Recommendation recommendation) { const string ADD = "Add new"; const string DELETE = "Delete existing"; const string NOOP = "No action"; var operations = new List { ADD, DELETE, NOOP }; var currentOptionSettingValue = _optionSettingHandler.GetOptionSettingValue>(recommendation, optionSetting) ?? new SortedSet(); while (true) { _interactiveService.WriteLine(); if (currentOptionSettingValue.Any()) { _interactiveService.WriteLine("Selected values:"); var currentOptionSettingValueList = currentOptionSettingValue.ToList(); for (int i = 1; i <= currentOptionSettingValueList.Count; i++) { var padLength = i.ToString().Length; _interactiveService.WriteLine($"{i.ToString().PadRight(padLength)}: {currentOptionSettingValueList[i-1]}"); } _interactiveService.WriteLine(); } var selectedOperation = AskUserToChoose(operations, "Select which operation you want to perform:", NOOP); _interactiveService.WriteLine(); if (selectedOperation.Equals(ADD)) { var userResponse = AskUserToChooseOrCreateNew(availableData, "Select value:", userInputConfiguration); if (userResponse.SelectedOption != null) currentOptionSettingValue.Add(userInputConfiguration.IDSelector(userResponse.SelectedOption)); } else if (selectedOperation.Equals(DELETE) && currentOptionSettingValue.Any()) { var selectedItem = AskUserToChoose(currentOptionSettingValue.ToList(), "Select the value you wish to delete:", null); currentOptionSettingValue.Remove(selectedItem); } else if (selectedOperation.Equals(NOOP)) { if (userInputConfiguration.CanBeEmpty || currentOptionSettingValue.Any()) break; else { _interactiveService.WriteErrorLine("The list cannot be empty. You must specify at least 1 value."); } } } _interactiveService.WriteLine(); return currentOptionSettingValue; } public Recommendation AskToChooseRecommendation(IList recommendations) { if (recommendations.Count == 0) { // This should never happen as application should have aborted sooner if there was no valid recommendations. throw new Exception("No recommendations available for user to select"); } _interactiveService.WriteLine("Recommended Deployment Option"); _interactiveService.WriteLine("-----------------------------"); _interactiveService.WriteLine($"1: {recommendations[0].Name}"); _interactiveService.WriteLine(recommendations[0].Description); _interactiveService.WriteLine(string.Empty); if (recommendations.Count > 1) { _interactiveService.WriteLine("Additional Deployment Options"); _interactiveService.WriteLine("------------------------------"); for (var index = 1; index < recommendations.Count; index++) { _interactiveService.WriteLine($"{index + 1}: {recommendations[index].Name}"); _interactiveService.WriteLine(recommendations[index].Description); _interactiveService.WriteLine(string.Empty); } } _interactiveService.WriteLine($"Choose deployment option (recommended default: 1)"); return ReadOptionFromUser(recommendations, 1); } public string AskUserToChoose(IList values, string title, string? defaultValue, string? defaultChoosePrompt = null) { var options = new List(); foreach (var value in values) { options.Add(new UserInputOption(value)); } UserInputOption? defaultOption = defaultValue != null ? new UserInputOption(defaultValue) : null; return AskUserToChoose(options, title, defaultOption, defaultChoosePrompt).Name; } public T AskUserToChoose(IList options, string title, T? defaultValue, string? defaultChoosePrompt = null) where T : IUserInputOption { var choosePrompt = !(string.IsNullOrEmpty(defaultChoosePrompt)) ? defaultChoosePrompt : "Choose option"; if (!string.IsNullOrEmpty(title)) { var dashLength = -1; foreach(var line in title.Split('\n')) { var length = line.Trim().Length; if(dashLength < length) { dashLength = length; } } _interactiveService.WriteLine(title); _interactiveService.WriteLine(new string('-', dashLength)); } var defaultValueIndex = -1; for (var i = 0; i < options.Count; i++) { if (string.Equals(options[i].Name, defaultValue?.Name)) { defaultValueIndex = i + 1; break; } } var optionNumber = 1; var padLength = options.Count.ToString().Length; foreach (var option in options) { var optionText = $"{optionNumber.ToString().PadRight(padLength)}: {option.Name}"; if(optionNumber == defaultValueIndex) { optionText += " (default)"; } _interactiveService.WriteLine(optionText); if (!string.IsNullOrEmpty(option.Description)) { _interactiveService.WriteLine($"{option.Description}"); _interactiveService.WriteLine(Environment.NewLine); } optionNumber++; } if (defaultValueIndex != -1) { _interactiveService.WriteLine(choosePrompt + $" (default {defaultValueIndex}):"); } else { if(options.Count == 1) { _interactiveService.WriteLine(choosePrompt + " (default 1):"); defaultValueIndex = 1; defaultValue = options[0]; } else { _interactiveService.WriteLine(choosePrompt + ":"); } } return ReadOptionFromUser(options, defaultValueIndex); } public void DisplayRow((string, int)[] row) { var blocks = new List(); for (var col = 0; col < row.Length; col++) { var (_, width) = row[col]; blocks.Add($"{{{col},{-width}}}"); } var values = row.Select(col => col.Item1).ToArray(); var format = string.Join(" | ", blocks); _interactiveService.WriteLine(string.Format(format, values)); } public UserResponse AskUserToChooseOrCreateNew(IEnumerable options, string title, bool askNewName = true, string defaultNewName = "", bool canBeEmpty = false, string? defaultChoosePrompt = null, string? defaultCreateNewPrompt = null, string? defaultCreateNewLabel = null) { var configuration = new UserInputConfiguration( idSelector: option => option, displaySelector: option => option, defaultSelector: option => option.Contains(option), defaultNewName: defaultNewName) { AskNewName = askNewName, CanBeEmpty = canBeEmpty }; return AskUserToChooseOrCreateNew(options, title, configuration, defaultChoosePrompt, defaultCreateNewPrompt, defaultCreateNewLabel); } public UserResponse AskUserToChooseOrCreateNew(IEnumerable options, string title, UserInputConfiguration userInputConfiguration, string? defaultChoosePrompt = null, string? defaultCreateNewPrompt = null, string? defaultCreateNewLabel = null) { var optionStrings = options.Select(userInputConfiguration.DisplaySelector); var defaultOption = options.FirstOrDefault(userInputConfiguration.DefaultSelector); var defaultValue = ""; var createNewLabel = !string.IsNullOrEmpty(defaultCreateNewLabel) ? defaultCreateNewLabel : Constants.CLI.CREATE_NEW_LABEL; if (defaultOption != null) { defaultValue = userInputConfiguration.DisplaySelector(defaultOption); } else { if (userInputConfiguration.CurrentValue != null && string.IsNullOrEmpty(userInputConfiguration.CurrentValue.ToString())) defaultValue = Constants.CLI.EMPTY_LABEL; else defaultValue = userInputConfiguration.CreateNew || !options.Any() ? createNewLabel : userInputConfiguration.DisplaySelector(options.First()); } var displayOptionStrings = new List(); // add empty option at the top if configured if (userInputConfiguration.EmptyOption) { displayOptionStrings.Add(Constants.CLI.EMPTY_LABEL); } // add all the options, this can be empty list if there are no options // e.g. selecting a role for a service when there are no roles with a service principal displayOptionStrings.AddRange(optionStrings); // add create new option at the bottom if configured if (userInputConfiguration.CreateNew) { displayOptionStrings.Add(createNewLabel); } // if list contains any options, ask user to choose one if (displayOptionStrings.Any()) { var selectedString = AskUserToChoose(displayOptionStrings, title, defaultValue, defaultChoosePrompt); if (selectedString == Constants.CLI.EMPTY_LABEL) { return new UserResponse { IsEmpty = true }; } if (selectedString != createNewLabel) { var selectedOption = options.FirstOrDefault(option => userInputConfiguration.DisplaySelector(option) == selectedString); return new UserResponse { SelectedOption = selectedOption, CreateNew = false }; } } if (userInputConfiguration.AskNewName) { var newName = AskUserForValue(string.Empty, userInputConfiguration.DefaultNewName, false, defaultAskValuePrompt: defaultCreateNewPrompt); return new UserResponse { CreateNew = true, NewName = newName }; } return new UserResponse { CreateNew = true, }; } public string AskUserForValue(string message, string defaultValue, bool allowEmpty, string resetValue = "", string? defaultAskValuePrompt = null, params Func>[] validators) { const string RESET = ""; var prompt = !string.IsNullOrEmpty(defaultAskValuePrompt) ? defaultAskValuePrompt : "Enter value"; if (!string.IsNullOrEmpty(defaultValue)) prompt += $" (default {defaultValue}"; if (!string.IsNullOrEmpty(message)) _interactiveService.WriteLine(message); if (allowEmpty) prompt += $". Type {RESET} to reset."; prompt += "): "; _interactiveService.WriteLine(prompt); string? userValue = null; while (true) { var line = _interactiveService.ReadLine()?.Trim() ?? ""; if (allowEmpty && (string.Equals(RESET, line.Trim(), StringComparison.OrdinalIgnoreCase) || string.Equals($"'{RESET}'", line.Trim(), StringComparison.OrdinalIgnoreCase))) { return resetValue; } if (string.IsNullOrEmpty(line) && !string.IsNullOrEmpty(defaultValue)) { return defaultValue; } userValue = line; if (!string.IsNullOrEmpty(defaultValue) && string.IsNullOrEmpty(userValue)) continue; var errorMessages = validators .Select(async v => await v(userValue)) .Select(v => v.Result) .Where(e => !string.IsNullOrEmpty(e)) .ToList(); if (errorMessages.Any()) { _interactiveService.WriteErrorLine(errorMessages.First()); continue; } break; } return userValue; } public SortedSet AskUserForList(SortedSet listValues) { listValues ??= new SortedSet(); if (listValues.Count == 0) { AskToAddListItem(listValues); return listValues; } const string ADD = "Add new"; const string UPDATE = "Update existing"; const string DELETE = "Delete existing"; const string NOOP = "No action"; var operations = new List { ADD, UPDATE, DELETE, NOOP }; var selectedOperation = AskUserToChoose(operations, "Select which operation you want to perform:", NOOP); if (selectedOperation.Equals(ADD)) AskToAddListItem(listValues); else if (selectedOperation.Equals(UPDATE)) AskToUpdateListItem(listValues); else if (selectedOperation.Equals(DELETE)) AskToDeleteListItem(listValues); return listValues; } private void AskToAddListItem(SortedSet listValues) { _interactiveService.WriteLine("Enter a value:"); var listValue = _interactiveService.ReadLine()?.Trim() ?? ""; if (!string.IsNullOrEmpty(listValue)) listValues.Add(listValue); } private void AskToUpdateListItem(SortedSet listValues) { var selectedItem = AskUserToChoose(listValues.ToList(), "Select the value you wish to update:", null); var selectedValue = AskUserForValue("Enter the updated value:", selectedItem, true); if (!string.IsNullOrEmpty(selectedValue)) { listValues.Remove(selectedItem); listValues.Add(selectedValue); } } private void AskToDeleteListItem(SortedSet listValues) { var selectedItem = AskUserToChoose(listValues.ToList(), "Select the value you wish to delete:", null); listValues.Remove(selectedItem); } public Dictionary AskUserForKeyValue(Dictionary keyValue) { keyValue ??= new Dictionary(); if (keyValue.Keys.Count == 0) { AskToAddKeyValuePair(keyValue); return keyValue; } const string ADD = "Add new"; const string UPDATE = "Update existing"; const string DELETE = "Delete existing"; var operations = new List { ADD, UPDATE, DELETE }; var selectedOperation = AskUserToChoose(operations, "Select which operation you want to perform:", ADD); if(selectedOperation.Equals(ADD)) AskToAddKeyValuePair(keyValue); if(selectedOperation.Equals(UPDATE)) AskToUpdateKeyValuePair(keyValue); if(selectedOperation.Equals(DELETE)) AskToDeleteKeyValuePair(keyValue); return keyValue; } private void AskToAddKeyValuePair(Dictionary keyValue) { const string RESET = ""; var variableName = string.Empty; while (string.IsNullOrEmpty(variableName)) { _interactiveService.WriteLine("Enter the name:"); variableName = _interactiveService.ReadLine()?.Trim() ?? ""; } _interactiveService.WriteLine($"Enter the value (type {RESET} to reset):"); var variableValue = _interactiveService.ReadLine()?.Trim() ?? ""; if (string.Equals(RESET, variableValue.Trim(), StringComparison.OrdinalIgnoreCase) || string.Equals($"'{RESET}'", variableValue.Trim(), StringComparison.OrdinalIgnoreCase)) variableValue = keyValue.ContainsKey(variableName) ? keyValue[variableName] : ""; keyValue[variableName] = variableValue; } private void AskToUpdateKeyValuePair(Dictionary keyValue) { var selectedKey = AskUserToChoose(keyValue.Keys.ToList(), "Select the one you wish to update:", null); var selectedValue = AskUserForValue("Enter the value:", keyValue[selectedKey], true); keyValue[selectedKey] = selectedValue; } private void AskToDeleteKeyValuePair(Dictionary keyValue) { var selectedKey = AskUserToChoose(keyValue.Keys.ToList(), "Select the one you wish to delete:", null); keyValue.Remove(selectedKey); } public string AskForEC2KeyPairSaveDirectory(string projectPath) { _interactiveService.WriteLine("Enter a directory to save the newly created Key Pair: (avoid from using your project directory)"); while (true) { var keyPairDirectory = _interactiveService.ReadLine(); if (keyPairDirectory != null && _directoryManager.Exists(keyPairDirectory)) { var projectFolder = new FileInfo(projectPath).Directory; var keyPairDirectoryInfo = new DirectoryInfo(keyPairDirectory); if (projectFolder != null && projectFolder.FullName.Equals(keyPairDirectoryInfo.FullName)) { _interactiveService.WriteLine(string.Empty); _interactiveService.WriteLine("EC2 Key Pair is a private secret key and it is recommended to not save the key in the project directory where it could be checked into source control."); var verification = AskYesNoQuestion("Are you sure you want to use your project directory?", "false"); if (verification == YesNo.No) { _interactiveService.WriteLine(string.Empty); _interactiveService.WriteLine("Please enter a valid directory:"); continue; } } return keyPairDirectory; } else { _interactiveService.WriteLine(string.Empty); _interactiveService.WriteLine("The directory you entered does not exist or is invalid."); _interactiveService.WriteLine("Please enter a valid directory:"); continue; } } } public YesNo AskYesNoQuestion(string question, string? defaultValue) { if (bool.TryParse(defaultValue, out var result)) return AskYesNoQuestion(question, result ? YesNo.Yes : YesNo.No); if (Enum.TryParse(defaultValue, out var result2)) return AskYesNoQuestion(question, result2); return AskYesNoQuestion(question); } public YesNo AskYesNoQuestion(string question, YesNo? defaultValue = default) { string message = string.Empty; if(!string.IsNullOrEmpty(question)) { message += question + ": "; } message += "y/n"; if (defaultValue.HasValue) { var defaultChar = defaultValue == YesNo.Yes ? 'y' : 'n'; message += $" (default {defaultChar})"; } _interactiveService.WriteLine(message); YesNo? selectedValue = null; while (selectedValue == null) { var line = _interactiveService.ReadLine()?.Trim(); if (string.IsNullOrEmpty(line) && defaultValue.HasValue) { selectedValue = defaultValue.Value; } else if (string.Equals(line, "y", StringComparison.OrdinalIgnoreCase)) { selectedValue = YesNo.Yes; } else if (String.Equals(line, "n", StringComparison.OrdinalIgnoreCase)) { selectedValue = YesNo.No; } else { _interactiveService.WriteLine($"Invalid option. The value should be either y or n."); } } return selectedValue.Value; } public void DisplayValues(Dictionary objectValues, string indent) { foreach (var (key, value) in objectValues) { if (value is Dictionary childObjectValue) { _interactiveService.WriteLine($"{indent}{key}"); DisplayValues(childObjectValue, $"{indent}\t"); } else if (value is SortedSet listValues) { _interactiveService.WriteLine($"{indent}{key}:"); foreach (var listValue in listValues) { _interactiveService.WriteLine($"{indent}\t{listValue}"); } } else if (value is string stringValue) { if (!string.IsNullOrEmpty(stringValue)) { _interactiveService.WriteLine($"{indent}{key}: {stringValue}"); } } else if(value != null) { _interactiveService.WriteLine($"{indent}{key}: {value}"); } } } private T ReadOptionFromUser(IList options, int defaultValueIndex) { if(options.Count == 0) { throw new Exception("No options available for user to select"); } // If defaultValueIndex is used it starts as 1 just like the user sees the list of options. if (defaultValueIndex != -1 && (defaultValueIndex < 1 || defaultValueIndex > options.Count)) { throw new Exception($"Invalid default index {defaultValueIndex}"); } while (true) { var selectedOption = _interactiveService.ReadLine(); if (string.IsNullOrEmpty(selectedOption) && defaultValueIndex != -1) { return options[defaultValueIndex - 1]; } if (int.TryParse(selectedOption, out var intOption) && intOption >= 1 && intOption <= options.Count) { return options[intOption - 1]; } _interactiveService.WriteLine($"Invalid option. The selected option should be between 1 and {options.Count}."); } } public string ReadSecretFromConsole() { var code = new StringBuilder(); while (true) { ConsoleKeyInfo i = _interactiveService.ReadKey(true); if (i.Key == ConsoleKey.Enter) { break; } else if (i.Key == ConsoleKey.Backspace) { if (code.Length > 0) { code.Remove(code.Length - 1, 1); _interactiveService.Write("\b \b"); } } // i.Key > 31: Skip the initial ascii control characters like ESC and tab. The space character is 32. // KeyChar == '\u0000' if the key pressed does not correspond to a printable character, e.g. F1, Pause-Break, etc else if ((int)i.Key > 31 && i.KeyChar != '\u0000') { code.Append(i.KeyChar); _interactiveService.Write("*"); } } return code.ToString().Trim(); } } }