From 2614ada3f3877b5fff55c343a3bb90743469ab94 Mon Sep 17 00:00:00 2001 From: chengggit Date: Sun, 4 Oct 2026 15:59:04 +0700 Subject: [PATCH 1/2] fix(installer): read keys from /dev/tty when stdin is piped Running `curl -fsSL .../install.py | python` made sys.stdin the pipe (exhausted after reading the script), so termios.tcgetattr(sys.stdin) crashed with "Inappropriate ioctl for device". - read keypresses and the custom path prompt from /dev/tty when stdin is not a TTY, matching how sys.stdin normally behaves - fall back to a plain numbered menu when no terminal exists at all - open /dev/tty with newline="\n" so the universal-newline decoder's pending-CR logic can't swallow the first Enter (needed two presses) --- install.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/install.py b/install.py index f8880fa..5111a17 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,6 +119,40 @@ 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 @@ -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": @@ -176,6 +215,14 @@ def get_key() -> str: 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 +259,14 @@ 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 + + path = input() if stream is sys.stdin else stream.readline() + path = path.strip() return path if path else None From fbcde5b6214c3133b01527cd98d938395033e894 Mon Sep 17 00:00:00 2001 From: chengggit Date: Sun, 4 Oct 2026 10:50:13 +0700 Subject: [PATCH 2/2] fix(installer): read custom path from console on Windows Piping the installer in (irm ... | python) makes stdin the pipe, so input() in ask_custom_path() raised EOFError at the Enter install path prompt. The arrow-key menu kept working because it reads the console via msvcrt instead of stdin; the prompt now does the same via a new read_line(), and EOF is treated as 'no path provided' instead of crashing. Also switch the TUI and messages to ASCII-only (selected marker, navigation hint, ellipsis, em dash) so rendering does not depend on UTF-8 console encoding. --- install.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/install.py b/install.py index 5111a17..c295081 100644 --- a/install.py +++ b/install.py @@ -159,12 +159,12 @@ def draw_menu(title: str, options: list[str], selected: int) -> int: 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 @@ -213,14 +213,54 @@ 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. + # 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}') + print(f'\n {GRAY}No terminal available - choosing "{options[0]}".{RESET}') return 0 selected = 0 @@ -265,7 +305,18 @@ def ask_custom_path() -> str | None: print() return None - path = input() if stream is sys.stdin else stream.readline() + 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 @@ -278,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")