b4797b056e
Every y/n prompt after the first in a session answered itself with its default and left the keypress queued for the next command line. MIN and TIME are not part of the canonical/non-canonical switch: they live in their own slots of the control-character array and survive `stty icanon`. drainTTY left them at "min 0 time 0" — return whatever is buffered, do not wait — and the restore named only icanon and echo, so the next read returned zero bytes without ever waiting for a key. Set MIN and TIME explicitly on the way in, and restore the terminal from the state captured with `stty -g` instead of naming the flags we changed; mgsh now hands the terminal back exactly as it found it, where before "min 0" outlived mgsh itself and broke the next program's single-key reads too. getkey also reports whether it got a key at all. When it did not, the answer is no whatever the default says: a question nobody saw must not be taken as consent. All five call sites default to no, so the bug never destroyed anything — it only made agreeing impossible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
124 lines
4.2 KiB
Go
124 lines
4.2 KiB
Go
package main
|
|
|
|
// input.go — the y/n prompts that guard the destructive commands.
|
|
//
|
|
// These questions are the only thing standing between `init` and a wiped
|
|
// server repository, so the terminal handling here has to be exactly right: a
|
|
// question that cannot be answered is worse than no question at all.
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/chzyer/readline"
|
|
)
|
|
|
|
// yesno asks a y/n question with a default and reads a single keypress. It is a
|
|
// variable because it guards the destructive operations: tests replace it to
|
|
// drive those paths without a terminal, and to assert that the question was
|
|
// asked at all.
|
|
//
|
|
// When the keypress cannot be read at all the answer is no, whatever the
|
|
// default says — a question nobody saw must never be taken as consent.
|
|
var yesno = func(prompt string, def bool) bool {
|
|
suffix := " y/N ? "
|
|
if def {
|
|
suffix = " Y/n ? "
|
|
}
|
|
key, ok := getkey(prompt + suffix)
|
|
if !ok {
|
|
errorln("could not read an answer from the terminal — assuming no")
|
|
return false
|
|
}
|
|
ans := strings.ToLower(strings.TrimSpace(key))
|
|
if ans == "" {
|
|
return def
|
|
}
|
|
return ans == "y"
|
|
}
|
|
|
|
// keyModeArgs put the terminal into single-key input: one keypress is delivered
|
|
// as it is typed, and it is not echoed.
|
|
//
|
|
// MIN and TIME are set explicitly, and that is not decoration. They are not
|
|
// part of the canonical/non-canonical switch — they live in their own slots of
|
|
// the control-character array and survive `stty icanon`. drainTTY leaves them
|
|
// at "min 0 time 0" ("return what is buffered, do not wait"), so without this
|
|
// the *second* question of a session read zero bytes, answered itself with its
|
|
// default, and left the keypress queued for the next prompt line.
|
|
var keyModeArgs = []string{"-icanon", "-echo", "min", "1", "time", "0"}
|
|
|
|
// getkey reads a single keypress from the terminal without echo, and reports
|
|
// whether it got one. It reads a single byte directly from stdin; readline only
|
|
// reads while it is inside Readline(), and we are inside command execution
|
|
// here, so there is no reader to desync with.
|
|
//
|
|
// Anything else already typed on the same line is discarded: answering "yes"
|
|
// to a y/n prompt must not leave "es\n" queued for the next readline call,
|
|
// where it would come back as a bogus command.
|
|
func getkey(prompt string) (string, bool) {
|
|
fmt.Print(prompt)
|
|
|
|
restore := func() {}
|
|
if stdinIsTTY() {
|
|
restore = singleKeyMode()
|
|
}
|
|
var buf [1]byte
|
|
n, err := os.Stdin.Read(buf[:])
|
|
restore()
|
|
|
|
if err != nil || n == 0 {
|
|
fmt.Println()
|
|
return "", false
|
|
}
|
|
key := strings.Trim(string(buf[:n]), "\r\n\t")
|
|
fmt.Println(key)
|
|
return key, true
|
|
}
|
|
|
|
// singleKeyMode switches the terminal to single-key input and returns the
|
|
// function that puts it back. The previous settings are restored verbatim from
|
|
// `stty -g` rather than by naming the flags we changed: naming them is how the
|
|
// MIN/TIME above were left behind in the first place, and mgsh should hand the
|
|
// terminal back exactly as it found it.
|
|
func singleKeyMode() func() {
|
|
saved, err := sttyRun("-g")
|
|
saved = strings.TrimSpace(saved)
|
|
sttyRun(keyModeArgs...)
|
|
|
|
if err != nil || saved == "" {
|
|
return func() { drainTTY(); sttyRun("icanon", "echo") } // best effort
|
|
}
|
|
return func() { drainTTY(); sttyRun(strings.Fields(saved)...) }
|
|
}
|
|
|
|
// drainTTY discards input already queued on the terminal. `min 0 time 0` makes
|
|
// a read return whatever is buffered without waiting, so this cannot block when
|
|
// nothing is pending. Its caller restores the terminal afterwards.
|
|
func drainTTY() {
|
|
sttyRun("-icanon", "-echo", "min", "0", "time", "0")
|
|
buf := make([]byte, 256)
|
|
for {
|
|
n, err := os.Stdin.Read(buf)
|
|
if err != nil || n == 0 {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// stdinIsTTY reports whether keypresses come from a terminal. A variable so the
|
|
// tests can exercise the terminal path without one.
|
|
var stdinIsTTY = func() bool { return readline.IsTerminal(int(os.Stdin.Fd())) }
|
|
|
|
// sttyRun runs stty on the terminal and returns its output. Errors are silent:
|
|
// every caller has a fallback, and a stray "stty: ..." line in the middle of a
|
|
// half-printed question helps nobody.
|
|
var sttyRun = func(args ...string) (string, error) {
|
|
c := exec.Command("stty", args...)
|
|
c.Stdin = os.Stdin
|
|
out, err := c.Output()
|
|
return string(out), err
|
|
}
|