mirror of
https://github.com/FiloSottile/age.git
synced 2026-03-11 08:55:41 +00:00
plugin: add NewTerminalUI
Closes #611 Closes #591 Co-authored-by: Nicolas Dumazet <nicdumz.commits@gmail.com>
This commit is contained in:
parent
a62324430d
commit
92ac13f51c
7 changed files with 267 additions and 238 deletions
|
|
@ -22,8 +22,8 @@ import (
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"filippo.io/age/agessh"
|
"filippo.io/age/agessh"
|
||||||
"filippo.io/age/armor"
|
"filippo.io/age/armor"
|
||||||
|
"filippo.io/age/internal/term"
|
||||||
"filippo.io/age/plugin"
|
"filippo.io/age/plugin"
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const usage = `Usage:
|
const usage = `Usage:
|
||||||
|
|
@ -251,7 +251,7 @@ func main() {
|
||||||
in = f
|
in = f
|
||||||
} else {
|
} else {
|
||||||
stdinInUse = true
|
stdinInUse = true
|
||||||
if decryptFlag && term.IsTerminal(int(os.Stdin.Fd())) {
|
if decryptFlag && term.IsTerminal(os.Stdin) {
|
||||||
// If the input comes from a TTY, assume it's armored, and buffer up
|
// If the input comes from a TTY, assume it's armored, and buffer up
|
||||||
// to the END line (or EOF/EOT) so that a password prompt or the
|
// to the END line (or EOF/EOT) so that a password prompt or the
|
||||||
// output don't get in the way of typing the input. See Issue 364.
|
// output don't get in the way of typing the input. See Issue 364.
|
||||||
|
|
@ -275,7 +275,7 @@ func main() {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
out = f
|
out = f
|
||||||
} else if term.IsTerminal(int(os.Stdout.Fd())) {
|
} else if term.IsTerminal(os.Stdout) {
|
||||||
if name != "-" {
|
if name != "-" {
|
||||||
if decryptFlag {
|
if decryptFlag {
|
||||||
// TODO: buffer the output and check it's printable.
|
// TODO: buffer the output and check it's printable.
|
||||||
|
|
@ -287,7 +287,7 @@ func main() {
|
||||||
`force anyway with "-o -"`)
|
`force anyway with "-o -"`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if in == os.Stdin && term.IsTerminal(int(os.Stdin.Fd())) {
|
if in == os.Stdin && term.IsTerminal(os.Stdin) {
|
||||||
// If the input comes from a TTY and output will go to a TTY,
|
// If the input comes from a TTY and output will go to a TTY,
|
||||||
// buffer it up so it doesn't get in the way of typing the input.
|
// buffer it up so it doesn't get in the way of typing the input.
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
|
|
@ -309,7 +309,7 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func passphrasePromptForEncryption() (string, error) {
|
func passphrasePromptForEncryption() (string, error) {
|
||||||
pass, err := readSecret("Enter passphrase (leave empty to autogenerate a secure one):")
|
pass, err := term.ReadSecret("Enter passphrase (leave empty to autogenerate a secure one):")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not read passphrase: %v", err)
|
return "", fmt.Errorf("could not read passphrase: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -325,7 +325,7 @@ func passphrasePromptForEncryption() (string, error) {
|
||||||
return "", fmt.Errorf("could not print passphrase: %v", err)
|
return "", fmt.Errorf("could not print passphrase: %v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
confirm, err := readSecret("Confirm passphrase:")
|
confirm, err := term.ReadSecret("Confirm passphrase:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not read passphrase: %v", err)
|
return "", fmt.Errorf("could not read passphrase: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -370,7 +370,7 @@ func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader
|
||||||
}
|
}
|
||||||
recipients = append(recipients, r...)
|
recipients = append(recipients, r...)
|
||||||
case "j":
|
case "j":
|
||||||
id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI)
|
id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorf("initializing %q: %v", f.Value, err)
|
errorf("initializing %q: %v", f.Value, err)
|
||||||
}
|
}
|
||||||
|
|
@ -450,7 +450,7 @@ func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) {
|
||||||
}
|
}
|
||||||
identities = append(identities, ids...)
|
identities = append(identities, ids...)
|
||||||
case "j":
|
case "j":
|
||||||
id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI)
|
id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorf("initializing %q: %v", f.Value, err)
|
errorf("initializing %q: %v", f.Value, err)
|
||||||
}
|
}
|
||||||
|
|
@ -509,7 +509,7 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) {
|
||||||
var lazyScryptIdentity = &LazyScryptIdentity{passphrasePromptForDecryption}
|
var lazyScryptIdentity = &LazyScryptIdentity{passphrasePromptForDecryption}
|
||||||
|
|
||||||
func passphrasePromptForDecryption() (string, error) {
|
func passphrasePromptForDecryption() (string, error) {
|
||||||
pass, err := readSecret("Enter passphrase:")
|
pass, err := term.ReadSecret("Enter passphrase:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not read passphrase: %v", err)
|
return "", fmt.Errorf("could not read passphrase: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"filippo.io/age/agessh"
|
"filippo.io/age/agessh"
|
||||||
"filippo.io/age/armor"
|
"filippo.io/age/armor"
|
||||||
|
"filippo.io/age/internal/term"
|
||||||
"filippo.io/age/plugin"
|
"filippo.io/age/plugin"
|
||||||
"filippo.io/age/tag"
|
"filippo.io/age/tag"
|
||||||
"golang.org/x/crypto/cryptobyte"
|
"golang.org/x/crypto/cryptobyte"
|
||||||
|
|
@ -37,7 +38,7 @@ func parseRecipient(arg string) (age.Recipient, error) {
|
||||||
case strings.HasPrefix(arg, "age1pq1"):
|
case strings.HasPrefix(arg, "age1pq1"):
|
||||||
return age.ParseHybridRecipient(arg)
|
return age.ParseHybridRecipient(arg)
|
||||||
case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1:
|
case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1:
|
||||||
return plugin.NewRecipient(arg, pluginTerminalUI)
|
return plugin.NewRecipient(arg, plugin.NewTerminalUI(printf, warningf))
|
||||||
case strings.HasPrefix(arg, "age1"):
|
case strings.HasPrefix(arg, "age1"):
|
||||||
return age.ParseX25519Recipient(arg)
|
return age.ParseX25519Recipient(arg)
|
||||||
case strings.HasPrefix(arg, "ssh-"):
|
case strings.HasPrefix(arg, "ssh-"):
|
||||||
|
|
@ -175,7 +176,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
|
||||||
return []age.Identity{&EncryptedIdentity{
|
return []age.Identity{&EncryptedIdentity{
|
||||||
Contents: contents,
|
Contents: contents,
|
||||||
Passphrase: func() (string, error) {
|
Passphrase: func() (string, error) {
|
||||||
pass, err := readSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name))
|
pass, err := term.ReadSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not read passphrase: %v", err)
|
return "", fmt.Errorf("could not read passphrase: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -211,7 +212,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
|
||||||
func parseIdentity(s string) (age.Identity, error) {
|
func parseIdentity(s string) (age.Identity, error) {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(s, "AGE-PLUGIN-"):
|
case strings.HasPrefix(s, "AGE-PLUGIN-"):
|
||||||
return plugin.NewIdentity(s, pluginTerminalUI)
|
return plugin.NewIdentity(s, plugin.NewTerminalUI(printf, warningf))
|
||||||
case strings.HasPrefix(s, "AGE-SECRET-KEY-1"):
|
case strings.HasPrefix(s, "AGE-SECRET-KEY-1"):
|
||||||
return age.ParseX25519Identity(s)
|
return age.ParseX25519Identity(s)
|
||||||
case strings.HasPrefix(s, "AGE-SECRET-KEY-PQ-1"):
|
case strings.HasPrefix(s, "AGE-SECRET-KEY-PQ-1"):
|
||||||
|
|
@ -265,7 +266,7 @@ func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
passphrasePrompt := func() ([]byte, error) {
|
passphrasePrompt := func() ([]byte, error) {
|
||||||
pass, err := readSecret(fmt.Sprintf("Enter passphrase for %q:", name))
|
pass, err := term.ReadSecret(fmt.Sprintf("Enter passphrase for %q:", name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err)
|
return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
180
cmd/age/tui.go
180
cmd/age/tui.go
|
|
@ -12,19 +12,19 @@ package main
|
||||||
//
|
//
|
||||||
// - Everything else goes to standard error with an "age:" prefix.
|
// - Everything else goes to standard error with an "age:" prefix.
|
||||||
// No capitalized initials and no periods at the end.
|
// No capitalized initials and no periods at the end.
|
||||||
|
//
|
||||||
|
// The one exception is the autogenerated passphrase, which goes to
|
||||||
|
// the terminal, since we really want it to reach the user only.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"filippo.io/age/armor"
|
"filippo.io/age/armor"
|
||||||
"filippo.io/age/plugin"
|
"filippo.io/age/internal/term"
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// l is a logger with no prefixes.
|
// l is a logger with no prefixes.
|
||||||
|
|
@ -53,183 +53,13 @@ func errorWithHint(error string, hints ...string) {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// avoidTerminalEscapeSequences is set if we need to avoid using escape
|
|
||||||
// sequences to prevent weird characters being printed to the console. This will
|
|
||||||
// happen on Windows when virtual terminal processing cannot be enabled.
|
|
||||||
var avoidTerminalEscapeSequences bool
|
|
||||||
|
|
||||||
// clearLine clears the current line on the terminal, or opens a new line if
|
|
||||||
// terminal escape codes don't work.
|
|
||||||
func clearLine(out io.Writer) {
|
|
||||||
const (
|
|
||||||
CUI = "\033[" // Control Sequence Introducer
|
|
||||||
CPL = CUI + "F" // Cursor Previous Line
|
|
||||||
EL = CUI + "K" // Erase in Line
|
|
||||||
)
|
|
||||||
|
|
||||||
// First, open a new line, which is guaranteed to work everywhere. Then, try
|
|
||||||
// to erase the line above with escape codes, if possible.
|
|
||||||
//
|
|
||||||
// (We use CRLF instead of LF to work around an apparent bug in WSL2's
|
|
||||||
// handling of CONOUT$. Only when running a Windows binary from WSL2, the
|
|
||||||
// cursor would not go back to the start of the line with a simple LF.
|
|
||||||
// Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.)
|
|
||||||
fmt.Fprintf(out, "\r\n")
|
|
||||||
if !avoidTerminalEscapeSequences {
|
|
||||||
fmt.Fprintf(out, CPL+EL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// withTerminal runs f with the terminal input and output files, if available.
|
|
||||||
// withTerminal does not open a non-terminal stdin, so the caller does not need
|
|
||||||
// to check stdinInUse.
|
|
||||||
func withTerminal(f func(in, out *os.File) error) error {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
in, err := os.OpenFile("CONIN$", os.O_RDWR, 0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer in.Close()
|
|
||||||
out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
return f(in, out)
|
|
||||||
} else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
|
|
||||||
defer tty.Close()
|
|
||||||
return f(tty, tty)
|
|
||||||
} else if term.IsTerminal(int(os.Stdin.Fd())) {
|
|
||||||
return f(os.Stdin, os.Stdin)
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printfToTerminal(format string, v ...interface{}) error {
|
func printfToTerminal(format string, v ...interface{}) error {
|
||||||
return withTerminal(func(_, out *os.File) error {
|
return term.WithTerminal(func(_, out *os.File) error {
|
||||||
_, err := fmt.Fprintf(out, "age: "+format+"\n", v...)
|
_, err := fmt.Fprintf(out, "age: "+format+"\n", v...)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// readSecret reads a value from the terminal with no echo. The prompt is ephemeral.
|
|
||||||
func readSecret(prompt string) (s []byte, err error) {
|
|
||||||
err = withTerminal(func(in, out *os.File) error {
|
|
||||||
fmt.Fprintf(out, "%s ", prompt)
|
|
||||||
defer clearLine(out)
|
|
||||||
s, err = term.ReadPassword(int(in.Fd()))
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// readPublic reads a value from the terminal. The prompt is ephemeral.
|
|
||||||
func readPublic(prompt string) (s []byte, err error) {
|
|
||||||
err = withTerminal(func(in, out *os.File) error {
|
|
||||||
fmt.Fprintf(out, "%s ", prompt)
|
|
||||||
defer clearLine(out)
|
|
||||||
|
|
||||||
oldState, err := term.MakeRaw(int(in.Fd()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer term.Restore(int(in.Fd()), oldState)
|
|
||||||
|
|
||||||
t := term.NewTerminal(in, "")
|
|
||||||
line, err := t.ReadLine()
|
|
||||||
s = []byte(line)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// readCharacter reads a single character from the terminal with no echo. The
|
|
||||||
// prompt is ephemeral.
|
|
||||||
func readCharacter(prompt string) (c byte, err error) {
|
|
||||||
err = withTerminal(func(in, out *os.File) error {
|
|
||||||
fmt.Fprintf(out, "%s ", prompt)
|
|
||||||
defer clearLine(out)
|
|
||||||
|
|
||||||
oldState, err := term.MakeRaw(int(in.Fd()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer term.Restore(int(in.Fd()), oldState)
|
|
||||||
|
|
||||||
b := make([]byte, 1)
|
|
||||||
if _, err := in.Read(b); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c = b[0]
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var pluginTerminalUI = &plugin.ClientUI{
|
|
||||||
DisplayMessage: func(name, message string) error {
|
|
||||||
printf("%s plugin: %s", name, message)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
RequestValue: func(name, message string, isSecret bool) (s string, err error) {
|
|
||||||
defer func() {
|
|
||||||
if err != nil {
|
|
||||||
warningf("could not read value for age-plugin-%s: %v", name, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if isSecret {
|
|
||||||
secret, err := readSecret(message)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(secret), nil
|
|
||||||
} else {
|
|
||||||
public, err := readPublic(message)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(public), nil
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Confirm: func(name, message, yes, no string) (choseYes bool, err error) {
|
|
||||||
defer func() {
|
|
||||||
if err != nil {
|
|
||||||
warningf("could not read value for age-plugin-%s: %v", name, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if no == "" {
|
|
||||||
message += fmt.Sprintf(" (press enter for %q)", yes)
|
|
||||||
_, err := readSecret(message)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no)
|
|
||||||
for {
|
|
||||||
selection, err := readCharacter(message)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
switch selection {
|
|
||||||
case '1':
|
|
||||||
return true, nil
|
|
||||||
case '2':
|
|
||||||
return false, nil
|
|
||||||
case '\x03': // CTRL-C
|
|
||||||
return false, errors.New("user cancelled prompt")
|
|
||||||
default:
|
|
||||||
warningf("reading value for age-plugin-%s: invalid selection %q", name, selection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
WaitTimer: func(name string) {
|
|
||||||
printf("waiting on %s plugin...", name)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func bufferTerminalInput(in io.Reader) (io.Reader, error) {
|
func bufferTerminalInput(in io.Reader) (io.Reader, error) {
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
if _, err := buf.ReadFrom(ReaderFunc(func(p []byte) (n int, err error) {
|
if _, err := buf.ReadFrom(ReaderFunc(func(p []byte) (n int, err error) {
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
// Copyright 2022 The age Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by a BSD-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Some instances of the Windows Console (e.g., cmd.exe and Windows PowerShell)
|
|
||||||
// do not have the virtual terminal processing enabled, which is necessary to
|
|
||||||
// make terminal escape sequences work. For this reason the clearLine function
|
|
||||||
// may not properly work. Here we enable the virtual terminal processing, if
|
|
||||||
// possible.
|
|
||||||
//
|
|
||||||
// See https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences.
|
|
||||||
func init() {
|
|
||||||
const (
|
|
||||||
ENABLE_PROCESSED_OUTPUT uint32 = 0x1
|
|
||||||
ENABLE_VIRTUAL_TERMINAL_PROCESSING uint32 = 0x4
|
|
||||||
)
|
|
||||||
|
|
||||||
kernel32DLL := windows.NewLazySystemDLL("Kernel32.dll")
|
|
||||||
setConsoleMode := kernel32DLL.NewProc("SetConsoleMode")
|
|
||||||
|
|
||||||
if err := withTerminal(func(in, out *os.File) error {
|
|
||||||
var mode uint32
|
|
||||||
if err := syscall.GetConsoleMode(syscall.Handle(out.Fd()), &mode); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
mode |= ENABLE_PROCESSED_OUTPUT
|
|
||||||
mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
|
||||||
|
|
||||||
// If the SetConsoleMode function fails, the return value is zero.
|
|
||||||
// See https://learn.microsoft.com/en-us/windows/console/setconsolemode#return-value.
|
|
||||||
if ret, _, _ := setConsoleMode.Call(out.Fd(), uintptr(mode)); ret == 0 {
|
|
||||||
return errors.New("SetConsoleMode failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
avoidTerminalEscapeSequences = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
internal/term/term.go
Normal file
122
internal/term/term.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package term
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// enableVirtualTerminalProcessing tries to enable virtual terminal processing
|
||||||
|
// on Windows. If it fails, avoid using escape sequences to prevent weird
|
||||||
|
// characters being printed to the console.
|
||||||
|
var enableVirtualTerminalProcessing func(out *os.File) error
|
||||||
|
|
||||||
|
// clearLine clears the current line on the terminal, or opens a new line if
|
||||||
|
// terminal escape codes don't work.
|
||||||
|
func clearLine(out *os.File) {
|
||||||
|
const (
|
||||||
|
CUI = "\033[" // Control Sequence Introducer
|
||||||
|
CPL = CUI + "F" // Cursor Previous Line
|
||||||
|
EL = CUI + "K" // Erase in Line
|
||||||
|
)
|
||||||
|
|
||||||
|
// First, open a new line, which is guaranteed to work everywhere. Then, try
|
||||||
|
// to erase the line above with escape codes, if possible.
|
||||||
|
//
|
||||||
|
// (We use CRLF instead of LF to work around an apparent bug in WSL2's
|
||||||
|
// handling of CONOUT$. Only when running a Windows binary from WSL2, the
|
||||||
|
// cursor would not go back to the start of the line with a simple LF.
|
||||||
|
// Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.)
|
||||||
|
fmt.Fprintf(out, "\r\n")
|
||||||
|
if enableVirtualTerminalProcessing == nil || enableVirtualTerminalProcessing(out) == nil {
|
||||||
|
fmt.Fprintf(out, CPL+EL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTerminal runs f with the terminal input and output files, if available.
|
||||||
|
// WithTerminal does not open a non-terminal stdin, so the caller does not need
|
||||||
|
// to check if stdin is in use.
|
||||||
|
func WithTerminal(f func(in, out *os.File) error) error {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
in, err := os.OpenFile("CONIN$", os.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
return f(in, out)
|
||||||
|
} else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
|
||||||
|
defer tty.Close()
|
||||||
|
return f(tty, tty)
|
||||||
|
} else if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
|
return f(os.Stdin, os.Stdin)
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadSecret reads a value from the terminal with no echo. The prompt is ephemeral.
|
||||||
|
func ReadSecret(prompt string) (s []byte, err error) {
|
||||||
|
err = WithTerminal(func(in, out *os.File) error {
|
||||||
|
fmt.Fprintf(out, "%s ", prompt)
|
||||||
|
defer clearLine(out)
|
||||||
|
s, err = term.ReadPassword(int(in.Fd()))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPublic reads a value from the terminal. The prompt is ephemeral.
|
||||||
|
func ReadPublic(prompt string) (s []byte, err error) {
|
||||||
|
err = WithTerminal(func(in, out *os.File) error {
|
||||||
|
fmt.Fprintf(out, "%s ", prompt)
|
||||||
|
defer clearLine(out)
|
||||||
|
|
||||||
|
oldState, err := term.MakeRaw(int(in.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer term.Restore(int(in.Fd()), oldState)
|
||||||
|
|
||||||
|
t := term.NewTerminal(in, "")
|
||||||
|
line, err := t.ReadLine()
|
||||||
|
s = []byte(line)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCharacter reads a single character from the terminal with no echo. The
|
||||||
|
// prompt is ephemeral.
|
||||||
|
func ReadCharacter(prompt string) (c byte, err error) {
|
||||||
|
err = WithTerminal(func(in, out *os.File) error {
|
||||||
|
fmt.Fprintf(out, "%s ", prompt)
|
||||||
|
defer clearLine(out)
|
||||||
|
|
||||||
|
oldState, err := term.MakeRaw(int(in.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer term.Restore(int(in.Fd()), oldState)
|
||||||
|
|
||||||
|
b := make([]byte, 1)
|
||||||
|
if _, err := in.Read(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c = b[0]
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTerminal returns whether the given file is a terminal.
|
||||||
|
func IsTerminal(f *os.File) bool {
|
||||||
|
return term.IsTerminal(int(f.Fd()))
|
||||||
|
}
|
||||||
48
internal/term/term_windows.go
Normal file
48
internal/term/term_windows.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// Copyright 2022 The age Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package term
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
enableVirtualTerminalProcessing = func(out *os.File) error {
|
||||||
|
// Some instances of the Windows Console (e.g., cmd.exe and Windows PowerShell)
|
||||||
|
// do not have the virtual terminal processing enabled, which is necessary to
|
||||||
|
// make terminal escape sequences work. For this reason the clearLine function
|
||||||
|
// may not properly work. Here we enable the virtual terminal processing, if
|
||||||
|
// possible.
|
||||||
|
//
|
||||||
|
// See https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences.
|
||||||
|
|
||||||
|
const (
|
||||||
|
ENABLE_PROCESSED_OUTPUT uint32 = 0x1
|
||||||
|
ENABLE_VIRTUAL_TERMINAL_PROCESSING uint32 = 0x4
|
||||||
|
)
|
||||||
|
|
||||||
|
kernel32DLL := windows.NewLazySystemDLL("Kernel32.dll")
|
||||||
|
setConsoleMode := kernel32DLL.NewProc("SetConsoleMode")
|
||||||
|
|
||||||
|
var mode uint32
|
||||||
|
if err := syscall.GetConsoleMode(syscall.Handle(out.Fd()), &mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
mode |= ENABLE_PROCESSED_OUTPUT
|
||||||
|
mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||||
|
|
||||||
|
// If the SetConsoleMode function fails, the return value is zero.
|
||||||
|
// See https://learn.microsoft.com/en-us/windows/console/setconsolemode#return-value.
|
||||||
|
if ret, _, _ := setConsoleMode.Call(out.Fd(), uintptr(mode)); ret == 0 {
|
||||||
|
return errors.New("SetConsoleMode failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
78
plugin/tui.go
Normal file
78
plugin/tui.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"filippo.io/age/internal/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewTerminalUI returns a [ClientUI] that uses the terminal to request inputs,
|
||||||
|
// and the provided functions to display messages and errors.
|
||||||
|
//
|
||||||
|
// The terminal is reached directly through /dev/tty or CONIN$/CONOUT$,
|
||||||
|
// bypassing standard input and output, so this UI can be used even when
|
||||||
|
// standard input or output are redirected.
|
||||||
|
func NewTerminalUI(printf, warningf func(format string, v ...interface{})) *ClientUI {
|
||||||
|
return &ClientUI{
|
||||||
|
DisplayMessage: func(name, message string) error {
|
||||||
|
printf("%s plugin: %s", name, message)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RequestValue: func(name, message string, isSecret bool) (s string, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
warningf("could not read value for age-plugin-%s: %v", name, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if isSecret {
|
||||||
|
secret, err := term.ReadSecret(message)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(secret), nil
|
||||||
|
} else {
|
||||||
|
public, err := term.ReadPublic(message)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(public), nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Confirm: func(name, message, yes, no string) (choseYes bool, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
warningf("could not read value for age-plugin-%s: %v", name, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if no == "" {
|
||||||
|
message += fmt.Sprintf(" (press enter for %q)", yes)
|
||||||
|
_, err := term.ReadSecret(message)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no)
|
||||||
|
for {
|
||||||
|
selection, err := term.ReadCharacter(message)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
switch selection {
|
||||||
|
case '1':
|
||||||
|
return true, nil
|
||||||
|
case '2':
|
||||||
|
return false, nil
|
||||||
|
case '\x03': // CTRL-C
|
||||||
|
return false, errors.New("user cancelled prompt")
|
||||||
|
default:
|
||||||
|
warningf("reading value for age-plugin-%s: invalid selection %q", name, selection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
WaitTimer: func(name string) {
|
||||||
|
printf("waiting on %s plugin...", name)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue