Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 115 additions & 10 deletions install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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":
Expand All @@ -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
Expand Down Expand Up @@ -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


Expand All @@ -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")
Expand Down
Loading