installer: attended curl|sh flow prompts on the terminal and offers model/runtime steps

- A bare terminal invocation reads answers from the real terminal (/dev/tty
  when stdin is the curl pipe); piped data is never used as an answer and
  nothing prompts without a controlling terminal.
- After the install choices it asks y/n separately for the speech model
  download and the pip runtime install, runs the chosen steps in order and
  stops at the first failure; equivalent flag commands are printed.
- Opens with a one-line note that the user can Ctrl+C and read the script.
- Drops the OpenVR autolaunch and --without-model questions from attended
  mode; the KDE desktop entry is the launch path. Flags are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baketnkandClaude Opus 5.5 committed 2026-09-25 10:59:04 -04:00
1 parent e5b998eb6c
commit e6cb07e8b8
5 files changed
+206 -50

No files matched your search

+7 -4
View File
@@ -20,8 +20,9 @@ a separately provisioned compatible CPU Python environment and pinned weights.
## OpenVR identity ## OpenVR identity
`local.frameyap.overlay` is a string OpenVR application key, **not** a Steam `local.frameyap.overlay` is a string OpenVR application key, **not** a Steam
store AppID. The installer creates a manifest/desktop launcher user-locally but store AppID. The installer creates a manifest and a desktop entry user-locally; the
does not register/launch the app by default. Explicit `frameyap --register desktop entry (read by KDE's menu) is the intended launch path, and the app
is not registered with SteamVR or launched by default. Explicit `frameyap --register
/absolute/manifest/path` uses the OpenVR registration API; registration alone /absolute/manifest/path` uses the OpenVR registration API; registration alone
did not reveal a launcher in the first checked dashboard menu. On one Frame the did not reveal a launcher in the first checked dashboard menu. On one Frame the
user opened the panel from the **Non-Steam** section and quit; shortcut discovery user opened the panel from the **Non-Steam** section and quit; shortcut discovery
@@ -57,8 +58,10 @@ already be available for registration; never start/restart it for installation.
authorized runtime/model can be set in `paths.conf`. authorized runtime/model can be set in `paths.conf`.
- **Noninteractive**: supply flags and `--yes` for network/model consent. - **Noninteractive**: supply flags and `--yes` for network/model consent.
`--print-plan` is read-only; `--json` provides structured results/errors and `--print-plan` is read-only; `--json` provides structured results/errors and
progress events for a model download. No prompt reads stdin in a pipe. An progress events for a model download. No prompt reads answers from a pipe.
empty TTY invocation offers a local menu and prints equivalent flags. A bare terminal invocation, including `curl … | sh`, prompts on the real
terminal (`/dev/tty`), asks y/n separately for the model and runtime
downloads, and prints the equivalent flag commands.
Installation is user-local under XDG data/config paths with a managed launcher, Installation is user-local under XDG data/config paths with a managed launcher,
retained rollback, SHA-256/path validation, foreign-file refusal and a lock retained rollback, SHA-256/path validation, foreign-file refusal and a lock
+13 -5
View File
@@ -86,7 +86,7 @@ sh install.sh --archive /path/to/frameyap-VERSION-linux-aarch64.tar.gz \
After an actual vetted release exists, use a real numeric version (for example, After an actual vetted release exists, use a real numeric version (for example,
`sh install.sh --mode binary --version 0.1.202609241627 --yes`); the installer `sh install.sh --mode binary --version 0.1.202609241627 --yes`); the installer
will retrieve tag `v0.1.202609241627` and its versioned checksum; no `latest` or moving-branch will retrieve tag `v0.1.202609241627` and its versioned checksum; no `latest` or moving-branch
lookup. A pipe invocation is supported, never prompts on stdin, and must also lookup. A pipe invocation is supported, never reads answers from the pipe, and must also
pin a real published tag. **There is no functional public download command yet.** pin a real published tag. **There is no functional public download command yet.**
`--without-model` omits any model files in the selected archive, never deletes `--without-model` omits any model files in the selected archive, never deletes
@@ -139,9 +139,17 @@ compiler, CMake, SDK, SDL3, Wayland/scanner, libxcb, FreeType, Vulkan developmen
files and producer-supplied files and producer-supplied
licenses; it still does not install Python ASR packages. `--print-plan` performs licenses; it still does not install Python ASR packages. `--print-plan` performs
a read-only plan, `--json` gives machine-readable results/errors (model installs a read-only plan, `--json` gives machine-readable results/errors (model installs
also stream file events), and `--yes` authorizes network downloads. Bare TTY also stream file events), and `--yes` authorizes network downloads.
invocation can guide choices and prints equivalent flags; non-TTY runs require A bare invocation with a terminal on stdout (including `curl … | sh`) runs
explicit arguments and never prompt. `--autolaunch`/`--no-autolaunch` are explicit the attended flow: it first notes that the user can press Ctrl+C and read the
script, asks for the install choices, then separately asks y/n before the
speech-model download (about 180 MB) and the pip runtime install, and runs the
chosen steps in order, stopping at the first failure. Answers come from the real
terminal (stdin if it is one, else `/dev/tty`); piped data is never read as an
answer, and with no controlling terminal nothing prompts. It prints the
equivalent flag commands for automation. Attended mode does not offer OpenVR
registration: the desktop entry is the launch path.
Runs with flags, or without a terminal, never prompt. `--autolaunch`/`--no-autolaunch` are explicit
OpenVR registration choices, off by default; do not pass either during an inert OpenVR registration choices, off by default; do not pass either during an inert
install if a running SteamVR session must remain untouched. install if a running SteamVR session must remain untouched.
@@ -188,7 +196,7 @@ Install root: `$XDG_DATA_HOME/frameyap` (default `~/.local/share/frameyap`);
launcher: `~/.local/bin/frameyap`; desktop entry: launcher: `~/.local/bin/frameyap`; desktop entry:
`$XDG_DATA_HOME/applications/frameyap.desktop` (default `$XDG_DATA_HOME/applications/frameyap.desktop` (default
`~/.local/share/applications/frameyap.desktop`). The desktop entry points to the `~/.local/share/applications/frameyap.desktop`). The desktop entry points to the
user-local launcher; the installer never edits Steam's library or registers a user-local launcher and is the intended launch path (KDE's menu reads it); the installer never edits Steam's library or registers a
Steam shortcut. Install/upgrade creates or checks `$XDG_CONFIG_HOME/frameyap/config.json` Steam shortcut. Install/upgrade creates or checks `$XDG_CONFIG_HOME/frameyap/config.json`
(default `~/.config/frameyap/config.json`), filling missing properties or repairing (default `~/.config/frameyap/config.json`), filling missing properties or repairing
invalid JSON/values while preserving valid customization. Before each repair it invalid JSON/values while preserving valid customization. Before each repair it
+57 -20
View File
@@ -1105,8 +1105,21 @@ def main(argv=None):
print("OpenVR autolaunch " + ("enabled" if args.autolaunch else "disabled") + " by explicit request") print("OpenVR autolaunch " + ("enabled" if args.autolaunch else "disabled") + " by explicit request")
def ask_yes_no(question):
while True:
answer = input(question + " [y/n]: ").strip().lower()
if answer in ("y", "yes"):
return True
if answer in ("n", "no"):
return False
print("Please answer y or n.")
def interactive_options(): def interactive_options():
"""Only an empty, actual terminal invocation offers a guided local choice.""" """Only an empty, actual terminal invocation offers a guided local choice.
Returns one argv per step; each step is an ordinary flag-driven operation."""
print("If you piped this script without reading it, you can press Ctrl+C now and read it first.")
print("FrameYap installer: choose binary (verified archive) or source (local build).") print("FrameYap installer: choose binary (verified archive) or source (local build).")
mode = input("Mode [binary/source]: ").strip().lower() mode = input("Mode [binary/source]: ").strip().lower()
if mode not in ("binary", "source"): if mode not in ("binary", "source"):
@@ -1119,44 +1132,68 @@ def interactive_options():
if archive: if archive:
chosen += ["--archive", archive, "--sha256", input("SHA-256 (64 hex digits): ").strip()] chosen += ["--archive", archive, "--sha256", input("SHA-256 (64 hex digits): ").strip()]
else: else:
if input("Download verified release v" + version + "? [yes/no]: ").strip().lower() != "yes": if not ask_yes_no("Download verified release v" + version + "?"):
raise UsageError("download not approved; no installation started") raise UsageError("download not approved; no installation started")
chosen.append("--yes") chosen.append("--yes")
else: else:
for flag in ("source", "openvr-root", "openvr-library", "openvr-license", "sdl-library", "sdl-license"): for flag in ("source", "openvr-root", "openvr-library", "openvr-license", "sdl-library", "sdl-license"):
chosen += ["--" + flag, input(flag + " local path: ").strip()] chosen += ["--" + flag, input(flag + " local path: ").strip()]
if input("Omit archive model files? [yes/no]: ").strip().lower() == "yes": steps = [chosen]
chosen.append("--without-model") # Each download is described and separately approved; nothing is fetched on a default.
if input("Enable OpenVR autolaunch? [yes/no]: ").strip().lower() == "yes": if ask_yes_no("Download the Parakeet Redux speech model (about 180 MB, CC-BY-4.0) from Hugging Face?"):
chosen.append("--autolaunch") steps.append(["--install-model", "--backend", "redux", "--yes"])
print("Equivalent flags: " + " ".join(shlex.quote(item) for item in chosen)) if ask_yes_no("Install the CPU Python runtime with pip (" + RUNTIME_DOWNLOAD + ")?"):
return chosen steps.append(["--install-runtime", "--yes"])
print("Equivalent commands:")
for step in steps:
print(" sh install.sh " + " ".join(shlex.quote(item) for item in step))
return steps
def attended_input():
"""The real terminal for prompts, or None. Under `curl | sh` stdin (and the
saved fd 3) is the pipe, so the controlling terminal is opened directly;
piped data is never read as answers."""
if sys.stdin.isatty():
return sys.stdin
try:
if os.isatty(3):
return os.fdopen(3, "r", closefd=False)
except OSError:
pass # Direct Python entry point, no saved shell descriptor.
try:
return open("/dev/tty", "r")
except OSError:
return None # no controlling terminal: never prompt
def cli(argv=None): def cli(argv=None):
argv = sys.argv[1:] if argv is None else argv argv = sys.argv[1:] if argv is None else argv
structured = "--json" in argv steps = [argv]
if not argv and sys.stdout.isatty(): if not argv and sys.stdout.isatty():
# With `sh install.sh`, fd 0 is the embedded Python code, not the # With `sh install.sh`, fd 0 is the embedded Python code, not the
# invoking terminal. fd 3 retains original stdin (including a pipe). # invoking terminal. fd 3 retains original stdin (including a pipe).
attended = sys.stdin attended = attended_input()
if not attended.isatty(): if attended is not None:
try:
if os.isatty(3):
attended = os.fdopen(3, "r", closefd=False)
except OSError:
pass # Direct Python entry point, no saved shell descriptor.
if attended.isatty():
try: try:
original_stdin = sys.stdin original_stdin = sys.stdin
try: try:
sys.stdin = attended sys.stdin = attended
argv = interactive_options() steps = interactive_options()
finally: finally:
sys.stdin = original_stdin sys.stdin = original_stdin
except (ValueError, EOFError) as exc: except (ValueError, EOFError, KeyboardInterrupt) as exc:
print(f"frameyap installer: {exc}", file=sys.stderr) print(f"frameyap installer: {exc or 'cancelled'}", file=sys.stderr)
return 2 return 2
for step in steps:
code = run_step(step)
if code:
return code
return 0
def run_step(argv):
structured = "--json" in argv
try: try:
if structured and "--print-plan" in argv: if structured and "--print-plan" in argv:
main(argv) # already emits one JSON plan main(argv) # already emits one JSON plan
+57 -20
View File
@@ -1085,8 +1085,21 @@ def main(argv=None):
print("OpenVR autolaunch " + ("enabled" if args.autolaunch else "disabled") + " by explicit request") print("OpenVR autolaunch " + ("enabled" if args.autolaunch else "disabled") + " by explicit request")
def ask_yes_no(question):
while True:
answer = input(question + " [y/n]: ").strip().lower()
if answer in ("y", "yes"):
return True
if answer in ("n", "no"):
return False
print("Please answer y or n.")
def interactive_options(): def interactive_options():
"""Only an empty, actual terminal invocation offers a guided local choice.""" """Only an empty, actual terminal invocation offers a guided local choice.
Returns one argv per step; each step is an ordinary flag-driven operation."""
print("If you piped this script without reading it, you can press Ctrl+C now and read it first.")
print("FrameYap installer: choose binary (verified archive) or source (local build).") print("FrameYap installer: choose binary (verified archive) or source (local build).")
mode = input("Mode [binary/source]: ").strip().lower() mode = input("Mode [binary/source]: ").strip().lower()
if mode not in ("binary", "source"): if mode not in ("binary", "source"):
@@ -1099,44 +1112,68 @@ def interactive_options():
if archive: if archive:
chosen += ["--archive", archive, "--sha256", input("SHA-256 (64 hex digits): ").strip()] chosen += ["--archive", archive, "--sha256", input("SHA-256 (64 hex digits): ").strip()]
else: else:
if input("Download verified release v" + version + "? [yes/no]: ").strip().lower() != "yes": if not ask_yes_no("Download verified release v" + version + "?"):
raise UsageError("download not approved; no installation started") raise UsageError("download not approved; no installation started")
chosen.append("--yes") chosen.append("--yes")
else: else:
for flag in ("source", "openvr-root", "openvr-library", "openvr-license", "sdl-library", "sdl-license"): for flag in ("source", "openvr-root", "openvr-library", "openvr-license", "sdl-library", "sdl-license"):
chosen += ["--" + flag, input(flag + " local path: ").strip()] chosen += ["--" + flag, input(flag + " local path: ").strip()]
if input("Omit archive model files? [yes/no]: ").strip().lower() == "yes": steps = [chosen]
chosen.append("--without-model") # Each download is described and separately approved; nothing is fetched on a default.
if input("Enable OpenVR autolaunch? [yes/no]: ").strip().lower() == "yes": if ask_yes_no("Download the Parakeet Redux speech model (about 180 MB, CC-BY-4.0) from Hugging Face?"):
chosen.append("--autolaunch") steps.append(["--install-model", "--backend", "redux", "--yes"])
print("Equivalent flags: " + " ".join(shlex.quote(item) for item in chosen)) if ask_yes_no("Install the CPU Python runtime with pip (" + RUNTIME_DOWNLOAD + ")?"):
return chosen steps.append(["--install-runtime", "--yes"])
print("Equivalent commands:")
for step in steps:
print(" sh install.sh " + " ".join(shlex.quote(item) for item in step))
return steps
def attended_input():
"""The real terminal for prompts, or None. Under `curl | sh` stdin (and the
saved fd 3) is the pipe, so the controlling terminal is opened directly;
piped data is never read as answers."""
if sys.stdin.isatty():
return sys.stdin
try:
if os.isatty(3):
return os.fdopen(3, "r", closefd=False)
except OSError:
pass # Direct Python entry point, no saved shell descriptor.
try:
return open("/dev/tty", "r")
except OSError:
return None # no controlling terminal: never prompt
def cli(argv=None): def cli(argv=None):
argv = sys.argv[1:] if argv is None else argv argv = sys.argv[1:] if argv is None else argv
structured = "--json" in argv steps = [argv]
if not argv and sys.stdout.isatty(): if not argv and sys.stdout.isatty():
# With `sh install.sh`, fd 0 is the embedded Python code, not the # With `sh install.sh`, fd 0 is the embedded Python code, not the
# invoking terminal. fd 3 retains original stdin (including a pipe). # invoking terminal. fd 3 retains original stdin (including a pipe).
attended = sys.stdin attended = attended_input()
if not attended.isatty(): if attended is not None:
try:
if os.isatty(3):
attended = os.fdopen(3, "r", closefd=False)
except OSError:
pass # Direct Python entry point, no saved shell descriptor.
if attended.isatty():
try: try:
original_stdin = sys.stdin original_stdin = sys.stdin
try: try:
sys.stdin = attended sys.stdin = attended
argv = interactive_options() steps = interactive_options()
finally: finally:
sys.stdin = original_stdin sys.stdin = original_stdin
except (ValueError, EOFError) as exc: except (ValueError, EOFError, KeyboardInterrupt) as exc:
print(f"frameyap installer: {exc}", file=sys.stderr) print(f"frameyap installer: {exc or 'cancelled'}", file=sys.stderr)
return 2 return 2
for step in steps:
code = run_step(step)
if code:
return code
return 0
def run_step(argv):
structured = "--json" in argv
try: try:
if structured and "--print-plan" in argv: if structured and "--print-plan" in argv:
main(argv) # already emits one JSON plan main(argv) # already emits one JSON plan
+72 -1
View File
@@ -745,8 +745,9 @@ class InstallTests(unittest.TestCase):
def test_piped_input_with_tty_output_does_not_prompt(self): def test_piped_input_with_tty_output_does_not_prompt(self):
master, slave = pty.openpty() master, slave = pty.openpty()
try: try:
# A new session has no controlling terminal: nothing to prompt on.
child = subprocess.Popen(["sh", str(REPO / "install.sh")], stdin=subprocess.PIPE, child = subprocess.Popen(["sh", str(REPO / "install.sh")], stdin=subprocess.PIPE,
stdout=slave, stderr=slave, stdout=slave, stderr=slave, start_new_session=True,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}) env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
os.close(slave) os.close(slave)
slave = -1 slave = -1
@@ -773,6 +774,76 @@ class InstallTests(unittest.TestCase):
if slave >= 0: if slave >= 0:
os.close(slave) os.close(slave)
def test_curl_pipe_prompts_on_controlling_terminal_not_the_pipe(self):
import fcntl as fcntl_module
import termios
master, slave = pty.openpty()
try:
def controlling_terminal():
fcntl_module.ioctl(1, termios.TIOCSCTTY, 0)
# `curl | sh`: stdin is a pipe; the pty is stdout and the session's terminal.
child = subprocess.Popen(["sh", str(REPO / "install.sh")], stdin=subprocess.PIPE,
stdout=slave, stderr=slave, start_new_session=True,
preexec_fn=controlling_terminal,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
os.close(slave)
slave = -1
output = bytearray()
try:
child.stdin.write(b"source\n") # piped data must never answer a prompt
child.stdin.close()
wrote = False
while child.poll() is None:
ready, _, _ = select.select([master], [], [], 8)
self.assertTrue(ready, "installer did not prompt on the terminal")
try:
output.extend(os.read(master, 8192))
except OSError:
break
if not wrote and b"Mode [binary/source]:" in output:
os.write(master, b"not-a-mode\n")
wrote = True
self.assertEqual(child.wait(timeout=8), 2)
self.assertIn(b"read it first", output)
self.assertIn(b"choose binary or source", output)
self.assertFalse((self.data / "frameyap").exists())
finally:
if child.poll() is None:
child.kill()
child.wait(timeout=8)
finally:
os.close(master)
if slave >= 0:
os.close(slave)
def test_attended_steps_ask_for_model_and_runtime_separately(self):
answers = iter(["binary", "0.1.202609241530", "/tmp/a.tar.gz", "a" * 64, "maybe", "y", "n"])
with patch("builtins.input", lambda prompt="": next(answers)), \
contextlib.redirect_stdout(io.StringIO()) as shown:
steps = installer.interactive_options()
self.assertEqual(steps, [["--mode", "binary", "--version", "0.1.202609241530",
"--archive", "/tmp/a.tar.gz", "--sha256", "a" * 64],
["--install-model", "--backend", "redux", "--yes"]])
self.assertIn("Please answer y or n.", shown.getvalue())
self.assertIn("sh install.sh --install-model --backend redux --yes", shown.getvalue())
answers = iter(["binary", "0.1.202609241530", "", "n"])
with patch("builtins.input", lambda prompt="": next(answers)), contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(ValueError, "download not approved"):
installer.interactive_options()
answers = iter(["binary", "0.1.202609241530", "", "yes", "n", "y"])
with patch("builtins.input", lambda prompt="": next(answers)), contextlib.redirect_stdout(io.StringIO()):
steps = installer.interactive_options()
self.assertEqual(steps[0][-1], "--yes")
self.assertEqual(steps[1:], [["--install-runtime", "--yes"]])
# Steps run in order and stop at the first failure.
calls = []
with patch.object(installer, "attended_input", return_value=sys.stdin), \
patch.object(installer, "interactive_options", return_value=[["a"], ["b"], ["c"]]), \
patch.object(installer, "run_step", side_effect=lambda argv: calls.append(argv) or (1 if argv == ["b"] else 0)), \
patch.object(installer.sys.stdout, "isatty", return_value=True):
self.assertEqual(installer.cli([]), 1)
self.assertEqual(calls, [["a"], ["b"]])
def test_attended_shell_wrapper_reads_the_actual_tty(self): def test_attended_shell_wrapper_reads_the_actual_tty(self):
master, slave = pty.openpty() master, slave = pty.openpty()
try: try: