diff --git a/install.py b/install.py index f8880fa..c295081 100644 --- a/install.py +++ b/install.py @@ -8,6 +8,7 @@ import venv from pathlib import Path from tempfile import TemporaryDirectory +from typing import TextIO from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen @@ -118,18 +119,52 @@ def clear_lines(n: int) -> None: sys.stdout.flush() +_tty_stream: TextIO | None = None +_tty_checked = False + + +def get_input_stream() -> TextIO | None: + """Return the stream keyboard input should be read from, if any. + + When the installer is piped in (curl ... | python), sys.stdin is the pipe + rather than the terminal, so read keys from the controlling terminal + instead. Returns None when no terminal is available at all. + """ + global _tty_stream, _tty_checked + + if os.name == "nt": + # msvcrt reads from the console directly, bypassing stdin. + return sys.stdin + + if not _tty_checked: + _tty_checked = True + if sys.stdin.isatty(): + _tty_stream = sys.stdin + else: + try: + # newline="\n" matches sys.stdin on POSIX: it disables the + # universal-newline decoder, whose pending-CR logic would + # swallow a lone "\r" (Enter) until another byte arrives, + # making the first Enter appear to do nothing. + _tty_stream = open("/dev/tty", newline="\n") + except OSError: + _tty_stream = None + + return _tty_stream + + def draw_menu(title: str, options: list[str], selected: int) -> int: """Render the menu to the terminal. Returns the number of lines drawn.""" lines = 0 sys.stdout.write(f"\n {BOLD}{title}{RESET}\n\n") lines += 3 # newline + title + blank for i, option in enumerate(options): - prefix = f"{GREEN}❯ {RESET}" if i == selected else " " + prefix = f"{GREEN}> {RESET}" if i == selected else " " color = BOLD if i == selected else "" reset = RESET if i == selected else "" sys.stdout.write(f" {prefix}{color}{option}{reset}\n") lines += 1 - sys.stdout.write(f"\n {GRAY}↑/↓ Navigate Enter Select{RESET}\n") + sys.stdout.write(f"\n {GRAY}Up/Down Navigate Enter Select{RESET}\n") lines += 2 # blank + hint sys.stdout.flush() return lines @@ -154,15 +189,19 @@ def get_key() -> str: import termios import tty - fd = sys.stdin.fileno() + stream = get_input_stream() + if stream is None: + raise KeyboardInterrupt + + fd = stream.fileno() old = termios.tcgetattr(fd) try: tty.setraw(fd) - ch = sys.stdin.read(1) - if ch == "\x03": + ch = stream.read(1) + if not ch or ch == "\x03": raise KeyboardInterrupt if ch == "\x1b": - ch += sys.stdin.read(2) + ch += stream.read(2) if ch == "\x1b[A": return "UP" if ch == "\x1b[B": @@ -174,8 +213,56 @@ def get_key() -> str: return "" +def read_line() -> str: + """Read one line of user input from the console. + + On Windows this reads via msvcrt instead of sys.stdin, so it still works + when the script is piped into Python (e.g. `curl ... | python`), where + stdin is not a TTY and input() would fail with EOFError. + """ + if os.name == "nt": + import msvcrt + + chars: list[str] = [] + while True: + ch = msvcrt.getwch() + + if ch in ("\r", "\n"): + sys.stdout.write("\n") + break + if ch == "\x03": # Ctrl+C + raise KeyboardInterrupt + if ch == "\x1a": # Ctrl+Z acts as EOF + raise EOFError + if ch in ("\x08", "\x7f"): # Backspace + if chars: + chars.pop() + sys.stdout.write("\b \b") + sys.stdout.flush() + continue + if ch in ("\x00", "\xe0"): # second half of a function key + msvcrt.getwch() + continue + if ch.isprintable(): + chars.append(ch) + sys.stdout.write(ch) + sys.stdout.flush() + + return "".join(chars) + + return input() + + def select_option(title: str, options: list[str]) -> int: """Display a menu and return the index of the chosen option.""" + if get_input_stream() is None: + # No terminal to read keypresses from - fall back to the first option. + print(f"\n {BOLD}{title}{RESET}\n") + for i, option in enumerate(options): + print(f" {i + 1}) {option}") + print(f'\n {GRAY}No terminal available - choosing "{options[0]}".{RESET}') + return 0 + selected = 0 # hide cursor @@ -212,7 +299,25 @@ def ask_custom_path() -> str | None: """Prompt the user to type a custom install path. Returns None on empty.""" sys.stdout.write(f"\n {CYAN}Enter install path:{RESET} ") sys.stdout.flush() - path = input().strip() + + stream = get_input_stream() + if stream is None: + print() + return None + + try: + # On Windows read_line() reads the console via msvcrt, because + # input() would hit EOFError when the script is piped in + # (irm ... | python). Elsewhere stream is either sys.stdin (a TTY) + # or the /dev/tty fallback for piped stdin. + path = read_line() if stream is sys.stdin else stream.readline() + except EOFError: + # stdin/console closed - treat like an empty answer instead of crashing + sys.stdout.write("\n") + sys.stdout.flush() + return None + + path = path.strip() return path if path else None @@ -224,15 +329,15 @@ def path_option() -> Path: choice = select_option(title, options) if choice == 0: - print(f"\n Installing to {DEFAULT_INSTALL_DIR} …\n") + print(f"\n Installing to {DEFAULT_INSTALL_DIR} ...\n") return DEFAULT_INSTALL_DIR elif choice == 1: path = ask_custom_path() if path: - print(f"\n Installing to {BOLD}{path}{RESET} …\n") + print(f"\n Installing to {BOLD}{path}{RESET} ...\n") return Path(path).expanduser() else: - print(f"\n {GRAY}No path provided — cancelled.{RESET}\n") + print(f"\n {GRAY}No path provided - cancelled.{RESET}\n") sys.exit(0) else: print(f"\n {GRAY}Installation cancelled.{RESET}\n")