import Foundation import XKit import SystemPackage import NIOPosix import NIOCore enum Console { private static func withStdio( _ body: ( _ stdin: NIOAsyncChannelInboundStream, _ stdout: NIOAsyncChannelOutboundWriter ) async throws -> T ) async throws -> T { try await NIOPipeBootstrap(group: .singletonMultiThreadedEventLoopGroup) .takingOwnershipOfDescriptors( input: FileDescriptor.standardInput.duplicate().rawValue, output: FileDescriptor.standardOutput.duplicate().rawValue ) .flatMapThrowing { try NIOAsyncChannel(wrappingChannelSynchronously: $0) } .get() .executeThenClose { try await body($0, $1) } } static func prompt(_ message: String) async throws -> String { try await withStdio { stdin, stdout in try await stdout.write(ByteBuffer(bytes: message.utf8)) fflush(stdoutSafe) var data = Data() for try await chunk in stdin { let view = chunk.readableBytesView if let endIndex = view.firstIndex(of: UInt8(ascii: "\n")) { data += view[.. String { let value: String if let existing { value = existing } else { value = try await Console.prompt(message) } guard !value.isEmpty else { throw Console.Error("Input cannot be empty.") } return value } static func getPassword(_ message: String) async throws -> String { if !message.isEmpty { print(message, terminator: "") } let password = try await withoutEcho { try await prompt("") } print() return password } private static func withoutEcho(_ action: () async throws -> T) async rethrows -> T { #if os(Windows) // based on https://stackoverflow.com/a/4497117/3769927 // TODO: Confirm that this works (or even compiles) let hConsole = CreateFileA("CONIN$", GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, 0, 0) var dwOldMode: DWORD = 0 GetConsoleMode(hConsole, &dwOldMode) let dwNewMode = dwOldMode & ~ENABLE_ECHO_INPUT SetConsoleMode(hConsole, dwNewMode) defer { SetConsoleMode(hConsole, dwOldMode) } return try action() #else var origAttr = termios() tcgetattr(STDIN_FILENO, &origAttr) var newAttr = origAttr newAttr.c_lflag = newAttr.c_lflag & ~tcflag_t(ECHO) tcsetattr(STDIN_FILENO, TCSANOW, &newAttr) defer { tcsetattr(STDIN_FILENO, TCSANOW, &origAttr) } return try await action() #endif } static func chooseNumber(in range: Range) async throws -> Int { let message = "Choice (\(range.lowerBound)-\(range.upperBound - 1)): " while true { if let choice = try await Int(prompt(message)), range.contains(choice) { return choice } } } static func choose( from elements: [T], onNoElement: () throws -> T, multiPrompt: @autoclosure () -> String, formatter: (T) throws -> String ) async throws -> T { switch elements.count { case 0: return try onNoElement() case 1: return elements[0] default: print(multiPrompt()) try elements.enumerated().forEach { index, element in try print("\(index): \(formatter(element))") } let choice = try await chooseNumber(in: elements.indices) return elements[choice] } } private static let yesSet: Set = ["yes", "y"] private static let noSet: Set = ["no", "n"] static func confirm(_ message: String) async throws -> Bool { while true { let resp = try await prompt("\(message) (yes/no): ").lowercased() if yesSet.contains(resp) { return true } else if noSet.contains(resp) { return false } } } struct Error: Swift.Error, CustomStringConvertible { let description: String init(_ description: String) { self.description = description } } }