internal/terminal: Interrogate and initialize the terminal, if any
This is a helper package that creates a very thin abstraction over terminal setup, with the main goal being to deal with all of the extra setup we need to do in order to get a UTF-8-supporting virtual terminal on a Windows system.
This commit is contained in:
parent
0a086030b1
commit
17728c8fe8
3
go.mod
3
go.mod
|
@ -123,7 +123,8 @@ require (
|
|||
golang.org/x/mod v0.3.0
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974
|
||||
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68
|
||||
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf
|
||||
golang.org/x/text v0.3.3
|
||||
golang.org/x/tools v0.0.0-20201028111035-eafbe7b904eb
|
||||
google.golang.org/api v0.34.0
|
||||
|
|
4
go.sum
4
go.sum
|
@ -746,6 +746,10 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M=
|
||||
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
// +build !windows
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// This is the implementation for all operating systems except Windows, where
|
||||
// we don't expect to need to do any special initialization to get a working
|
||||
// Virtual Terminal.
|
||||
//
|
||||
// For this implementation we just delegate everything upstream to
|
||||
// golang.org/x/term, since it already has a variety of different
|
||||
// implementations for quirks of more esoteric operating systems like plan9,
|
||||
// and will hopefully grow to include others as Go is ported to other platforms
|
||||
// in future.
|
||||
//
|
||||
// For operating systems that golang.org/x/term doesn't support either, it
|
||||
// defaults to indicating that nothing is a terminal and returns an error when
|
||||
// asked for a size, which we'll handle below.
|
||||
|
||||
func configureOutputHandle(f *os.File) (*OutputStream, error) {
|
||||
return &OutputStream{
|
||||
File: f,
|
||||
isTerminal: isTerminalGolangXTerm,
|
||||
getColumns: getColumnsGolangXTerm,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func configureInputHandle(f *os.File) (*InputStream, error) {
|
||||
return &InputStream{
|
||||
File: f,
|
||||
isTerminal: isTerminalGolangXTerm,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isTerminalGolangXTerm(f *os.File) bool {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
|
||||
func getColumnsGolangXTerm(f *os.File) int {
|
||||
width, _, err := term.GetSize(int(f.Fd()))
|
||||
if err != nil {
|
||||
// Suggests that it's either not a terminal at all or that we're on
|
||||
// a platform that golang.org/x/term doesn't support. In both cases
|
||||
// we'll just return the placeholder default value.
|
||||
return defaultColumns
|
||||
}
|
||||
return width
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
// +build windows
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
// We're continuing to use this third-party library on Windows because it
|
||||
// has the additional IsCygwinTerminal function, which includes some useful
|
||||
// heuristics for recognizing when a pipe seems to be connected to a
|
||||
// legacy terminal emulator on Windows versions that lack true pty support.
|
||||
// We now use golang.org/x/term's functionality on other platforms.
|
||||
isatty "github.com/mattn/go-isatty"
|
||||
)
|
||||
|
||||
func configureOutputHandle(f *os.File) (*OutputStream, error) {
|
||||
ret := &OutputStream{
|
||||
File: f,
|
||||
}
|
||||
|
||||
if fd := f.Fd(); isatty.IsTerminal(fd) {
|
||||
// We have a few things to deal with here:
|
||||
// - Activating UTF-8 output support (mandatory)
|
||||
// - Activating virtual terminal support (optional)
|
||||
// These will not succeed on Windows 8 or early versions of Windows 10.
|
||||
|
||||
// UTF-8 support means switching the console "code page" to CP_UTF8.
|
||||
// Notice that this doesn't take the specific file descriptor, because
|
||||
// the console is just ambiently associated with our process.
|
||||
err := SetConsoleOutputCP(CP_UTF8)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set the console to UTF-8 mode; you may need to use a newer version of Windows: %s", err)
|
||||
}
|
||||
|
||||
// If the console also allows us to turn on
|
||||
// ENABLE_VIRTUAL_TERMINAL_PROCESSING then we can potentially use VT
|
||||
// output, although the methods of Settings will make the final
|
||||
// determination on that because we might have some handles pointing at
|
||||
// terminals and other handles pointing at files/pipes.
|
||||
ret.getColumns = getColumnsWindowsConsole
|
||||
var mode uint32
|
||||
err = windows.GetConsoleMode(windows.Handle(fd), &mode)
|
||||
if err != nil {
|
||||
return ret, nil // We'll treat this as success but without VT support
|
||||
}
|
||||
mode |= windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||
err = windows.SetConsoleMode(windows.Handle(fd), mode)
|
||||
if err != nil {
|
||||
return ret, nil // We'll treat this as success but without VT support
|
||||
}
|
||||
|
||||
// If we get here then we've successfully turned on VT processing, so
|
||||
// we can return an OutputStream that answers true when asked if it
|
||||
// is a Terminal.
|
||||
ret.isTerminal = staticTrue
|
||||
return ret, nil
|
||||
|
||||
} else if isatty.IsCygwinTerminal(fd) {
|
||||
// Cygwin terminals -- and other VT100 "fakers" for older versions of
|
||||
// Windows -- are not really terminals in the usual sense, but rather
|
||||
// are pipes between the child process (Terraform) and the terminal
|
||||
// emulator. isatty.IsCygwinTerminal uses some heuristics to
|
||||
// distinguish those pipes from other pipes we might see if the user
|
||||
// were, for example, using the | operator on the command line.
|
||||
// If we get in here then we'll assume that we can send VT100 sequences
|
||||
// to this stream, even though it isn't a terminal in the usual sense.
|
||||
|
||||
ret.isTerminal = staticTrue
|
||||
// TODO: Is it possible to detect the width of these fake terminals?
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// If we fall out here then we have a non-terminal filehandle, so we'll
|
||||
// just accept all of the default OutputStream behaviors
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func configureInputHandle(f *os.File) (*InputStream, error) {
|
||||
ret := &InputStream{
|
||||
File: f,
|
||||
}
|
||||
|
||||
if fd := f.Fd(); isatty.IsTerminal(fd) {
|
||||
// We have to activate UTF-8 input, or else we fail. This will not
|
||||
// succeed on Windows 8 or early versions of Windows 10.
|
||||
// Notice that this doesn't take the specific file descriptor, because
|
||||
// the console is just ambiently associated with our process.
|
||||
err := SetConsoleCP(CP_UTF8)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set the console to UTF-8 mode; you may need to use a newer version of Windows: %s", err)
|
||||
}
|
||||
ret.isTerminal = staticTrue
|
||||
return ret, nil
|
||||
} else if isatty.IsCygwinTerminal(fd) {
|
||||
// As with the output handles above, we'll use isatty's heuristic to
|
||||
// pretend that a pipe from mintty or a similar userspace terminal
|
||||
// emulator is actually a terminal.
|
||||
ret.isTerminal = staticTrue
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// If we fall out here then we have a non-terminal filehandle, so we'll
|
||||
// just accept all of the default InputStream behaviors
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func getColumnsWindowsConsole(f *os.File) int {
|
||||
// We'll just unconditionally ask the given file for its console buffer
|
||||
// info here, and let it fail if the file isn't actually a console.
|
||||
// (In practice, the init functions above only hook up this function
|
||||
// if the handle looks like a console, so this should succeed.)
|
||||
var info windows.ConsoleScreenBufferInfo
|
||||
err := windows.GetConsoleScreenBufferInfo(windows.Handle(f.Fd()), &info)
|
||||
if err != nil {
|
||||
return defaultColumns
|
||||
}
|
||||
return int(info.Size.X)
|
||||
}
|
||||
|
||||
// Unfortunately not all of the Windows kernel functions we need are in
|
||||
// x/sys/windows at the time of writing, so we need to call some of them
|
||||
// directly. (If you're maintaining this in future and have the capacity to
|
||||
// test it well, consider checking if these functions have been added upstream
|
||||
// yet and switch to their wrapper stubs if so.
|
||||
var modkernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
var procSetConsoleCP = modkernel32.NewProc("SetConsoleCP")
|
||||
var procSetConsoleOutputCP = modkernel32.NewProc("SetConsoleOutputCP")
|
||||
|
||||
const CP_UTF8 = 65001
|
||||
|
||||
// (These are written in the style of the stubs in x/sys/windows, which is
|
||||
// a little non-idiomatic just due to the awkwardness of the low-level syscall
|
||||
// interface.)
|
||||
|
||||
func SetConsoleCP(codepageID uint32) (err error) {
|
||||
r1, _, e1 := syscall.Syscall(procSetConsoleCP.Addr(), 1, uintptr(codepageID), 0, 0)
|
||||
if r1 == 0 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func SetConsoleOutputCP(codepageID uint32) (err error) {
|
||||
r1, _, e1 := syscall.Syscall(procSetConsoleOutputCP.Addr(), 1, uintptr(codepageID), 0, 0)
|
||||
if r1 == 0 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func staticTrue(f *os.File) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func staticFalse(f *os.File) bool {
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package terminal
|
||||
|
||||
import "os"
|
||||
|
||||
// This file has some annoying nonsense to, yet again, work around the
|
||||
// panicwrap hack.
|
||||
//
|
||||
// Specifically, typically when we're running Terraform the stderr handle is
|
||||
// not directly connected to the terminal but is instead a pipe into a parent
|
||||
// process gathering up the output just in case a panic message appears.
|
||||
// However, this package needs to know whether the _real_ stderr is connected
|
||||
// to a terminal and what its width is.
|
||||
//
|
||||
// To work around that, we'll first initialize the terminal in the parent
|
||||
// process, and then capture information about stderr into an environment
|
||||
// variable so we can pass it down to the child process. The child process
|
||||
// will then use the environment variable to pretend that the panicwrap pipe
|
||||
// has the same characteristics as the terminal that it's indirectly writing
|
||||
// to.
|
||||
//
|
||||
// This file has some helpers for implementing that awkward handshake, but the
|
||||
// handshake itself is in package main, interspersed with all of the other
|
||||
// panicwrap machinery.
|
||||
//
|
||||
// You might think that the code in helper/wrappedstreams could avoid this
|
||||
// problem, but that package is broken on Windows: it always fails to recover
|
||||
// the real stderr, and it also gets an incorrect result if the user was
|
||||
// redirecting or piping stdout/stdin. So... we have this hack instead, which
|
||||
// gets a correct result even on Windows and even with I/O redirection.
|
||||
|
||||
// StateForAfterPanicWrap is part of the workaround for panicwrap that
|
||||
// captures some characteristics of stderr that the caller can pass to the
|
||||
// panicwrap child process somehow and then use ReinitInsidePanicWrap.
|
||||
func (s *Streams) StateForAfterPanicWrap() *PrePanicwrapState {
|
||||
return &PrePanicwrapState{
|
||||
StderrIsTerminal: s.Stderr.IsTerminal(),
|
||||
StderrWidth: s.Stderr.Columns(),
|
||||
}
|
||||
}
|
||||
|
||||
// ReinitInsidePanicwrap is part of the workaround for panicwrap that
|
||||
// produces a Streams containing a potentially-lying Stderr that might
|
||||
// claim to be a terminal even if it's actually a pipe connected to the
|
||||
// parent process.
|
||||
//
|
||||
// That's an okay lie in practice because the parent process will copy any
|
||||
// data it recieves via that pipe verbatim to the real stderr anyway. (The
|
||||
// original call to Init in the parent process should've already done any
|
||||
// necessary modesetting on the Stderr terminal, if any.)
|
||||
//
|
||||
// The state argument can be nil if we're not running in panicwrap mode,
|
||||
// in which case this function behaves exactly the same as Init.
|
||||
func ReinitInsidePanicwrap(state *PrePanicwrapState) (*Streams, error) {
|
||||
ret, err := Init()
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
if state != nil {
|
||||
// A lying stderr, then.
|
||||
ret.Stderr = &OutputStream{
|
||||
File: ret.Stderr.File,
|
||||
isTerminal: func(f *os.File) bool {
|
||||
return state.StderrIsTerminal
|
||||
},
|
||||
getColumns: func(f *os.File) int {
|
||||
return state.StderrWidth
|
||||
},
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// PrePanicwrapState is a horrible thing we use to work around panicwrap,
|
||||
// related to both Streams.StateForAfterPanicWrap and ReinitInsidePanicwrap.
|
||||
type PrePanicwrapState struct {
|
||||
StderrIsTerminal bool
|
||||
StderrWidth int
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package terminal
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
const defaultColumns int = 78
|
||||
const defaultIsTerminal bool = false
|
||||
|
||||
// OutputStream represents an output stream that might or might not be connected
|
||||
// to a terminal.
|
||||
//
|
||||
// There are typically two instances of this: one representing stdout and one
|
||||
// representing stderr.
|
||||
type OutputStream struct {
|
||||
File *os.File
|
||||
|
||||
// Interacting with a terminal is typically platform-specific, so we
|
||||
// factor out these into virtual functions, although we have default
|
||||
// behaviors suitable for non-Terminal output if any of these isn't
|
||||
// set. (We're using function pointers rather than interfaces for this
|
||||
// because it allows us to mix both normal methods and virtual methods
|
||||
// on the same type, without a bunch of extra complexity.)
|
||||
isTerminal func(*os.File) bool
|
||||
getColumns func(*os.File) int
|
||||
}
|
||||
|
||||
// Columns returns a number of character cell columns that we expect will
|
||||
// fill the width of the terminal that stdout is connected to, or a reasonable
|
||||
// placeholder value of 78 if the output doesn't seem to be a terminal.
|
||||
//
|
||||
// This is a best-effort sort of function which may give an inaccurate result
|
||||
// in various cases. For example, callers storing the result will not react
|
||||
// to subsequent changes in the terminal width, and indeed this function itself
|
||||
// may not be able to either, depending on the constraints of the current
|
||||
// execution context.
|
||||
func (s *OutputStream) Columns() int {
|
||||
if s.getColumns == nil {
|
||||
return defaultColumns
|
||||
}
|
||||
return s.getColumns(s.File)
|
||||
}
|
||||
|
||||
// IsTerminal returns true if we expect that the stream is connected to a
|
||||
// terminal which supports VT100-style formatting and cursor control sequences.
|
||||
func (s *OutputStream) IsTerminal() bool {
|
||||
if s.isTerminal == nil {
|
||||
return defaultIsTerminal
|
||||
}
|
||||
return s.isTerminal(s.File)
|
||||
}
|
||||
|
||||
// InputStream represents an input stream that might or might not be a terminal.
|
||||
//
|
||||
// There is typically only one instance of this type, representing stdin.
|
||||
type InputStream struct {
|
||||
File *os.File
|
||||
|
||||
// Interacting with a terminal is typically platform-specific, so we
|
||||
// factor out these into virtual functions, although we have default
|
||||
// behaviors suitable for non-Terminal output if any of these isn't
|
||||
// set. (We're using function pointers rather than interfaces for this
|
||||
// because it allows us to mix both normal methods and virtual methods
|
||||
// on the same type, without a bunch of extra complexity.)
|
||||
isTerminal func(*os.File) bool
|
||||
}
|
||||
|
||||
// IsTerminal returns true if we expect that the stream is connected to a
|
||||
// terminal which can support interactive input.
|
||||
//
|
||||
// If this returns false, callers might prefer to skip elaborate input prompt
|
||||
// functionality like tab completion and instead just treat the input as a
|
||||
// raw byte stream, or perhaps skip prompting for input at all depending on the
|
||||
// situation.
|
||||
func (s *InputStream) IsTerminal() bool {
|
||||
if s.isTerminal == nil {
|
||||
return defaultIsTerminal
|
||||
}
|
||||
return s.isTerminal(s.File)
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
// Package terminal encapsulates some platform-specific logic for detecting
|
||||
// if we're running in a terminal and, if so, properly configuring that
|
||||
// terminal to meet the assumptions that the rest of Terraform makes.
|
||||
//
|
||||
// Specifically, Terraform requires a Terminal which supports virtual terminal
|
||||
// sequences and which accepts UTF-8-encoded text.
|
||||
//
|
||||
// This is an abstraction only over the platform-specific detection of and
|
||||
// possibly initialization of terminals. It's not intended to provide
|
||||
// higher-level abstractions of the sort provided by packages like termcap or
|
||||
// curses; ultimately we just assume that terminals are "standard" VT100-like
|
||||
// terminals and use a subset of control codes that works across the various
|
||||
// platforms we support. Our approximate target is "xterm-compatible"
|
||||
// virtual terminals.
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// Streams represents a collection of three streams that each may or may not
|
||||
// be connected to a terminal.
|
||||
//
|
||||
// If a stream is connected to a terminal then there are more possibilities
|
||||
// available, such as detecting the current terminal width. If we're connected
|
||||
// to something else, such as a pipe or a file on disk, the stream will
|
||||
// typically provide placeholder values or do-nothing stubs for
|
||||
// terminal-requiring operatons.
|
||||
//
|
||||
// Note that it's possible for only a subset of the streams to be connected
|
||||
// to a terminal. For example, this happens if the user runs Terraform with
|
||||
// I/O redirection where Stdout might refer to a regular disk file while Stderr
|
||||
// refers to a terminal, or various other similar combinations.
|
||||
type Streams struct {
|
||||
Stdout *OutputStream
|
||||
Stderr *OutputStream
|
||||
Stdin *InputStream
|
||||
}
|
||||
|
||||
// Init tries to initialize a terminal, if Terraform is running in one, and
|
||||
// returns an object describing what it was able to set up.
|
||||
//
|
||||
// An error for this function indicates that the current execution context
|
||||
// can't meet Terraform's assumptions. For example, on Windows Init will return
|
||||
// an error if Terraform is running in a Windows Console that refuses to
|
||||
// activate UTF-8 mode, which can happen if we're running on an unsupported old
|
||||
// version of Windows.
|
||||
//
|
||||
// Note that the success of this function doesn't mean that we're actually
|
||||
// running in a terminal. It could also represent successfully detecting that
|
||||
// one or more of the input/output streams is not a terminal.
|
||||
func Init() (*Streams, error) {
|
||||
// These configure* functions are platform-specific functions in other
|
||||
// files that use //+build constraints to vary based on target OS.
|
||||
|
||||
stderr, err := configureOutputHandle(os.Stderr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := configureOutputHandle(os.Stdout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdin, err := configureInputHandle(os.Stdin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Streams{
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
Stdin: stdin,
|
||||
}, nil
|
||||
}
|
Loading…
Reference in New Issue