Skip to content

fix(install): read prompts from /dev/tty so curl|sh can answer them#213

Merged
srsholmes merged 5 commits into
mainfrom
fix/install-prompts-tty
Jul 15, 2026
Merged

fix(install): read prompts from /dev/tty so curl|sh can answer them#213
srsholmes merged 5 commits into
mainfrom
fix/install-prompts-tty

Conversation

@srsholmes

Copy link
Copy Markdown
Owner

Problem

The README documents install and uninstall as curl -fsSL … | sh. Under a
pipe the shell reads the script itself from stdin, so [ -t 0 ] is false
and prompt_yn took its default every time. Every prompt in both scripts was
unanswerable via the exact command we tell people to run.

Three bugs fell out of that, all silent:

  1. Phase 2 could never run. A curl|sh install always printed
    Skipping Phase 2 — no input group, no InputPlumber — leaving the overlay
    unable to capture controllers. The user only ever sees one [INFO] line
    about it in a wall of output.
  2. curl|sh upgrades silently kept the old binary. Re-running the install
    hit Loadout binary already exists. Overwrite? (y/N) → default no →
    INSTALL_BINARY=0. The documented upgrade path never upgraded the binary.
  3. The uninstaller could never remove plugin data or config — both prompts
    defaulted to no.

The intent was there but unreachable: phase2_input_group carried a branch
commented "Non-interactive (curl|sh): default to yes since the overlay is
useless without it"
— dead code for the exact case it named, because the outer
Phase 2 gate defaulted to no and phase2 never ran.

Fix

Read the controlling terminal rather than stdin. /dev/tty exists under
both curl … | sh and sh install.sh, so prompts reach the user in both. With
genuinely no terminal (CI, systemd, docker run -d) PROMPT_TTY is empty and
prompts fall back to defaults rather than hanging.

prompt_yn grows a separate non-interactive default ($3), kept distinct
from the <enter> default ($2). This lets the Phase 2 gate treat <enter> as
consent while still refusing to sudo, install a package and mask HHD
unattended — the gate is the single place that declines headless runs, so the
inner prompts don't each need their own guard.

Phase 2 and InputPlumber now default to yes on <enter>: the overlay can't
capture controllers without them, so the single documented command should
produce a working install. Both remain declinable with n.

Also drops phase2_input_group's bespoke read in favour of the shared
helper, deleting the unreachable branch described above.

Verification

prompt_yn extracted from install.sh (the real code, not a copy) and driven
under a real PTY with the script piped into sh — i.e. the exact curl|sh
shape, where stdin is the pipe and /dev/tty is the terminal:

Scenario Answer Result
curl|sh in a terminal y accepted — prompt now reaches the user at all
curl|sh in a terminal <enter> takes the default
curl|sh in a terminal n declines, overriding a yes-default
No terminal (setsid, stdin /dev/null) falls back to defaults, does not hang

Phase 2 gate specifically ("y" "n"): <enter>runs, n → skips,
no terminal → skips (no unattended sudo).

Confirmed reading /dev/tty does not disturb the shell consuming the script
from the pipe — the script runs to completion.

Both scripts pass sh -n.

Not covered: a full end-to-end curl|sh install against a live release
wasn't re-run, since the prompt path is exercised directly above.

Notes

Independent of #211 — that fixes an overlay GL segfault and touches the overlay
.service heredoc; this touches prompt_yn/phase2 ~800 lines away. No
conflict expected.

🤖 Generated with Claude Code

srsholmes and others added 2 commits July 14, 2026 14:17
The README install/uninstall are documented as `curl -fsSL … | sh`. Under
a pipe the shell's stdin is the script text, not the user, so `[ -t 0 ]`
was false and prompt_yn silently returned its default every time — the
prompts were unanswerable via the one command we tell people to run.

Three bugs fell out of that, all invisible:

  - Phase 2 (input group + InputPlumber) could never run, so a curl|sh
    install left the overlay unable to capture controllers.
  - Re-running the install to upgrade always took the default on
    "binary already exists. Overwrite?" — i.e. no, silently keeping the
    old binary forever.
  - The uninstaller could never ask about removing plugin data or config.

Read the controlling terminal instead, which exists under both `curl|sh`
and `sh install.sh`. With genuinely no terminal (CI, systemd) prompts
still fall back to defaults rather than hanging.

prompt_yn grows a separate non-interactive default ($3) so the Phase 2
gate can treat <enter> as consent while still refusing to sudo, install a
package and mask HHD unattended. Phase 2 and InputPlumber now default to
yes on <enter>: the overlay can't capture controllers without them, so
the single documented command should get a working install.

This also drops phase2_input_group's bespoke read, whose "Non-interactive
(curl|sh): default to yes" branch was unreachable for the exact case it
named — the outer gate defaulted to no and phase2 never ran.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /dev/tty fix is what makes these prompts reachable at all, which is
right -- but it also promotes Phase 2 from dead code to default-yes, and
Phase 2 was making two claims that were not true. Reachable + untrue is a
much worse combination than unreachable + untrue, so fix the claims.

1. SteamOS: the text promised "IP ships disabled -- this step leaves it
   untouched ... so it stays opt-in", while both installer paths ended in
   `systemctl enable --now inputplumber.service`. On a stock Deck IP is
   installed but inactive, so the short-circuit guard doesn't fire, and
   <enter> would enable IP and claim the built-in controller -- exactly
   what the text said it wouldn't, and what the design says belongs to
   the user in-app when they pick a wake button.

   Add IP_ENABLE (default 1) to the IP installer and route every
   enable/start path through a single ip_enable(). install.sh passes
   IP_ENABLE=0 on ID=steamos, so the promise is now real. The plugin
   backend keeps the default and still enables IP for the in-app flow.

   Passed via `sudo env IP_ENABLE=…`, not `sudo IP_ENABLE=…`: the latter
   is subject to sudoers' env_reset and can be silently dropped, which
   would default back to 1 and re-enable IP on a Deck -- the exact bug
   this fixes, reintroduced by a stripped variable.

2. HHD: install.sh claimed the installer "stops + masks any conflicting
   hhd*.service units". Nothing in the tree has ever done that -- the
   only hhd code is the guard that checks whether it's active. On Bazzite
   (rpm-ostree -> tarball path -> enable) HHD keeps running and IP comes
   up alongside it, both contesting controller HID: the precise conflict
   the text claimed was handled.

   Refuse instead of implementing it. Bazzite ships HHD as a base
   package, and masking it takes the user's TDP/RGB/controller stack down
   with it -- not something to do because someone pressed <enter>. Detect
   active HHD, explain the conflict, point at the one-liner, and skip.

CachyOS is unaffected and still gets what it needs: no HHD, ID != steamos
=> IP_ENABLE=1 => pacman install + enable, exactly as before.

Verified on SteamOS: ID=steamos resolves to IP_ENABLE=0; ip_enable()
skips enable/restart at 0 and runs both at 1; no enable path remains
outside the helper; no HHD active so the normal prompt path is unchanged.
Both scripts pass syntax checks.

Bazzite is reasoned, not measured -- I have no Bazzite device here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@srsholmes

Copy link
Copy Markdown
Owner Author

The /dev/tty fix is correct — but it unblocked two false promises. Both fixed (cbbd596)

Reviewed on SteamOS (ID=steamos, ID_LIKE=arch, no /run/ostree-booted, pacman present, systemd 257).

The tty diagnosis is right and the bug is real. The concern is what it unblocks: Phase 2 goes from unreachable dead code to default-yes, and Phase 2 was making two claims that were not true. Reachable + untrue is much worse than unreachable + untrue.

🚩 1. The SteamOS contradiction

install.sh told the user:

"On SteamOS Deck IP is already installed but ships disabled — this step leaves it untouched. Enabling it (and claiming the Deck's built-in controller) happens in-app when you choose an overlay wake button, so it stays opt-in."

But I traced the branch this device actually takes (IS_RPM_OSTREE=0, pacman present, HAYSTACK="steamos arch" matches *arch*):

if pacman -Qi inputplumber; then log "already installed"; else pacman -S ...; fi
systemctl daemon-reload
systemctl enable --now inputplumber.service   # ← unconditional

Both installer paths (pacman and the tarball fallback at the end) finish with an enable+start. On a stock Deck IP is installed but inactive, so the short-circuit guard (is-active) doesn't fire → prompt → <enter>IP enabled and the built-in controller claimed — exactly what the text promised wouldn't happen, and what the design says belongs to the user in-app.

My box only escapes because IP is already active here, so the guard skips.

Fix: added IP_ENABLE (default 1) to the IP installer and routed every enable/start path through a single ip_enable(). install.sh passes IP_ENABLE=0 on ID=steamos. The promise is now real. The plugin backend keeps the default, so the in-app flow still enables IP as before.

Subtle bit: passed via sudo env IP_ENABLE=…, not sudo IP_ENABLE=…. The latter is subject to sudoers' env_reset and can be silently dropped — which would leave the script defaulting back to 1 and enabling IP on a Deck anyway, reintroducing the exact bug via a stripped variable.

Verified on this device:

ID=steamos -> IP_ENABLE=0 (leave IP disabled)  ✅
IP_ENABLE=0: daemon-reload only, no enable/restart
IP_ENABLE=1: daemon-reload + enable + restart

No enable path remains outside the helper.

🚩 2. The HHD claim is fiction

install.sh claimed, in both a comment and to the user:

#   - Stops + masks any conflicting hhd*.service units
info "If Handheld Daemon (HHD) is running it'll be stopped and masked —"
info "it conflicts with IP over controller HID ownership."

Nothing in the tree has ever done this. I grepped the whole repo: the only hhd code is the guard that checks whether it's active. install-inputplumber.sh mentions HHD only in a comment explaining why the COPR build can't be layered.

Consequence on Bazzite: rpm-ostree → skips pacman/dnf → tarball path → systemctl enable inputplumber.service. HHD keeps running and IP comes up alongside it, both contesting controller HID ownership — precisely the conflict the text claimed was handled. Bazzite ships HHD as a base package, so this is the common case, not an edge.

Previously harmless because curl|sh never ran Phase 2. With this PR, <enter> reaches it.

Fix: refuse rather than implement. Masking HHD takes a Bazzite user's TDP / RGB / controller stack down with it — not something to do because someone pressed <enter>. So we detect it, explain, and skip:

if systemctl list-units --plain --no-legend --type=service --state=active 'hhd*' 2>/dev/null | grep -q hhd; then
    warn "Handheld Daemon (HHD) is active. It contests controller HID ownership"
    warn "with InputPlumber, so Loadout won't install IP alongside it."
    warn "If you want IP, disable HHD yourself first:"
    warn "  sudo systemctl mask --now hhd@\$USER.service"
    return
fi

Both false claims are gone from install.sh; the comment now says plainly that the installer does not touch HHD.

CachyOS is unaffected

No HHD active, ID != steamosIP_ENABLE=1 → pacman install + enable + start, exactly as before. The <enter>-to-working-install intent of this PR is preserved — it just no longer does the thing the docs promise it won't on the two handheld distros.

Caveat

Bazzite is reasoned, not measured — I have no Bazzite device. The SteamOS findings above are all measured on hardware.

🤖 Reviewed and tested with Claude Code on SteamOS

srsholmes and others added 2 commits July 15, 2026 09:55
…safe

Text and comments only -- no behaviour change.

install.sh told the user "If Handheld Daemon (HHD) is running it'll be
stopped and masked", and the function comment claimed the installer
"stops + masks any conflicting hhd*.service units". Nothing in the tree
has ever done either; the only hhd code is the guard that checks whether
it's active. Drop both claims rather than implement them -- masking HHD
takes a user's TDP/RGB/controller stack down with it, which is not
something to do because someone pressed <enter>.

Also record what actually makes the surrounding promises true, because it
isn't obvious and I got it wrong reading this the first time:
install-inputplumber.sh's detect_installed() exits "nothing to do" before
the package-manager branch whenever IP is on PATH. SteamOS ships IP with
the image (disabled) and Bazzite ships it too, so on both the installer
short-circuits before it can enable anything. That -- not the prose -- is
what keeps "leaves it untouched" honest on a Deck and what stops IP being
enabled alongside a live HHD. Noted so the gate doesn't get removed as a
redundant optimisation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@srsholmes

Copy link
Copy Markdown
Owner Author

⚠️ Correction — my earlier findings here were wrong. Reverted.

Retracting my previous comment. Both "🚩 blocker" findings in it were wrong, and the fix I pushed on the back of them (cbbd596) has been reverted in 3696c28. Sorry — that comment was posted with more confidence than the evidence supported, and it was being read on another device as fact.

What I missed

An early-exit gate in install-inputplumber.sh that runs before the package-manager branch:

 94:  detect_installed() { command -v inputplumber && return 0; … }
102:  if detect_installed; then
104:      log "InputPlumber already installed at $bin — nothing to do."
          exit 0
      fi
109:  # ── 2. Package manager fast path ──   ← the `enable --now` lives BELOW this

I traced the pacman branch and reported that it enables IP unconditionally, without checking what reaches it. On this SteamOS box:

$ command -v inputplumber
/usr/bin/inputplumber
$ pacman -Qi inputplumber
Name: inputplumber   Version: 0.77.7-1   Validated By: Signature

IP is on PATH, so detect_installed returns 0 and the script exits before the enable ever runs.

Finding 1 (SteamOS "leaves it untouched") — WRONG

The promise was already true. Stock SteamOS ships InputPlumber with the image, so detect_installed short-circuits and nothing is enabled. The claim held; it just held via that gate rather than via the text. My IP_ENABLE change guarded a path SteamOS never reaches — dead code.

Finding 2 (Bazzite IP-alongside-HHD conflict) — WRONG

Same gate. Bazzite ships IP too, so detect_installed exits there as well and the conflict I described doesn't arise. Confirmed by the maintainer: the installer has always worked on Bazzite — this app was originally developed on it — without any of these changes. I asserted a break that field evidence says never happened. HHD is also being deprecated, and Bazzite will ship IP in future versions, so the gate will keep covering it.

Worse, the HHD refusal I added would have been the actual regression in this PR: it returned early and blocked an IP install on Bazzite that is known to work today. A hard stop, traded for nothing.

What's left (ef17e36) — text only, zero behaviour change

One real defect survives, and it's documentation:

#   - Stops + masks any conflicting hhd*.service units
info "If Handheld Daemon (HHD) is running it'll be stopped and masked —"

Nothing in the tree has ever done that. Both claims are now dropped rather than implemented — masking HHD takes a user's TDP/RGB/controller stack down with it, which shouldn't happen because someone pressed <enter>.

The comment also now records why the surrounding promises hold, since that's what I misread: detect_installed is load-bearing, not an optimisation. It's what keeps "leaves it untouched" honest on a Deck and what stops IP coming up next to a live HHD. Flagged so nobody deletes it as redundant.

Verified: comments and info strings only — no non-comment line changed, syntax checks pass.

So: is this PR's payload actually risky?

No. The /dev/tty fix is a real bug — prompts genuinely are unanswerable under the documented curl … | sh, so Phase 2 could never run and the input group never got added, which is exactly what CachyOS needs. And default-yes has no teeth on the two handheld distros, because detect_installed makes the IP step a no-op on both.

My apologies for the noise. The /dev/tty diagnosis was right all along; the blast-radius argument I built on top of it was not.

🤖 Claude Code — corrected after re-checking on SteamOS hardware

…ler is the upgrade path

The prompt is reachable now (/dev/tty), but an <enter> or headless re-run
still kept the old binary. The binary lives in ~/.local/bin (no sudo) and
the fresh-install path installs it without asking, so overwrite defaults
to yes in both modes. Still declinable with n.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@srsholmes srsholmes merged commit 3a6f7f2 into main Jul 15, 2026
2 checks passed
@srsholmes srsholmes deleted the fix/install-prompts-tty branch July 15, 2026 10:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant