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 }