Skip to content

Commit 80bb36a

Browse files
authored
Merge pull request #2327 from ActiveState/mitchell/dx-1527-2
Do not use terminal escape sequences in non-interactive terminals or the Windows command prompt.
2 parents 1a92bec + cde3aad commit 80bb36a

8 files changed

Lines changed: 162 additions & 59 deletions

File tree

cmd/state/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ func main() {
8282

8383
// Set up our output formatter/writer
8484
outFlags := parseOutputFlags(os.Args)
85-
out, err := initOutput(outFlags, "")
85+
shellName, _ := subshell.DetectShell(cfg)
86+
out, err := initOutput(outFlags, "", shellName)
8687
if err != nil {
8788
multilog.Critical("Could not initialize outputer: %s", errs.JoinMessage(err))
8889
os.Stderr.WriteString(locale.Tr("err_main_outputer", err.Error()))

cmd/state/main_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,37 +15,37 @@ type MainTestSuite struct {
1515

1616
func (suite *MainTestSuite) TestOutputer() {
1717
{
18-
outputer, err := initOutput(outputFlags{"", false, false, false}, "")
18+
outputer, err := initOutput(outputFlags{"", false, false, false}, "", "")
1919
suite.Require().NoError(err, errs.Join(err, "\n").Error())
2020
suite.Equal(output.PlainFormatName, outputer.Type(), "Returns Plain outputer")
2121
}
2222

2323
{
24-
outputer, err := initOutput(outputFlags{string(output.PlainFormatName), false, false, false}, "")
24+
outputer, err := initOutput(outputFlags{string(output.PlainFormatName), false, false, false}, "", "")
2525
suite.Require().NoError(err)
2626
suite.Equal(output.PlainFormatName, outputer.Type(), "Returns Plain outputer")
2727
}
2828

2929
{
30-
outputer, err := initOutput(outputFlags{string(output.JSONFormatName), false, false, false}, "")
30+
outputer, err := initOutput(outputFlags{string(output.JSONFormatName), false, false, false}, "", "")
3131
suite.Require().NoError(err)
3232
suite.Equal(output.JSONFormatName, outputer.Type(), "Returns JSON outputer")
3333
}
3434

3535
{
36-
outputer, err := initOutput(outputFlags{"", false, false, false}, string(output.JSONFormatName))
36+
outputer, err := initOutput(outputFlags{"", false, false, false}, string(output.JSONFormatName), "")
3737
suite.Require().NoError(err)
3838
suite.Equal(output.JSONFormatName, outputer.Type(), "Returns JSON outputer")
3939
}
4040

4141
{
42-
outputer, err := initOutput(outputFlags{"", false, false, false}, string(output.EditorFormatName))
42+
outputer, err := initOutput(outputFlags{"", false, false, false}, string(output.EditorFormatName), "")
4343
suite.Require().NoError(err)
4444
suite.Equal(output.EditorFormatName, outputer.Type(), "Returns JSON outputer")
4545
}
4646

4747
{
48-
outputer, err := initOutput(outputFlags{"", false, false, false}, string(output.EditorV0FormatName))
48+
outputer, err := initOutput(outputFlags{"", false, false, false}, string(output.EditorV0FormatName), "")
4949
suite.Require().NoError(err)
5050
suite.Equal(output.EditorV0FormatName, outputer.Type(), "Returns JSON outputer")
5151
}

cmd/state/output.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@ import (
44
"errors"
55
"os"
66

7-
"github.com/jessevdk/go-flags"
8-
97
"github.com/ActiveState/cli/internal/errs"
108
"github.com/ActiveState/cli/internal/logging"
119
"github.com/ActiveState/cli/internal/multilog"
1210
"github.com/ActiveState/cli/internal/output"
1311
"github.com/ActiveState/cli/internal/rollbar"
1412
"github.com/ActiveState/cli/internal/terminal"
15-
13+
"github.com/jessevdk/go-flags"
14+
"golang.org/x/term"
1615
survey "gopkg.in/AlecAivazis/survey.v1/core"
1716
)
1817

@@ -47,7 +46,7 @@ func parseOutputFlags(args []string) outputFlags {
4746
return flagSet
4847
}
4948

50-
func initOutput(flags outputFlags, formatName string) (output.Outputer, error) {
49+
func initOutput(flags outputFlags, formatName string, shellName string) (output.Outputer, error) {
5150
if formatName == "" {
5251
formatName = flags.Output
5352
}
@@ -56,13 +55,14 @@ func initOutput(flags outputFlags, formatName string) (output.Outputer, error) {
5655
OutWriter: os.Stdout,
5756
ErrWriter: os.Stderr,
5857
Colored: !flags.DisableColor(),
59-
Interactive: true,
58+
Interactive: term.IsTerminal(int(os.Stdin.Fd())),
59+
ShellName: shellName,
6060
})
6161
if err != nil {
6262
if errors.Is(err, output.ErrNotRecognized) {
6363
// The formatter might still be registered, so default to plain for now
6464
logging.Warning("Output format not recognized: %s, defaulting to plain output instead", formatName)
65-
return initOutput(flags, string(output.PlainFormatName))
65+
return initOutput(flags, string(output.PlainFormatName), shellName)
6666
}
6767
multilog.Log(logging.ErrorNoStacktrace, rollbar.Error)("Could not create outputer, name: %s, error: %s", formatName, err.Error())
6868
return nil, errs.Wrap(err, "output.New %s failed", formatName)

internal/output/output.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,5 @@ type Config struct {
9090
ErrWriter io.Writer
9191
Colored bool
9292
Interactive bool
93+
ShellName string
9394
}

internal/output/progress.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import (
66
"time"
77
)
88

9-
const moveCaretBack = "\x1b[%dD" // %d is the number of characters to move back
9+
const moveCaretBackEscapeSequence = "\x1b[%dD" // %d is the number of characters to move back
1010

1111
type Spinner struct {
12-
frame int
13-
frames []string
14-
out Outputer
15-
stop chan struct{}
16-
interval time.Duration
12+
frame int
13+
frames []string
14+
out Outputer
15+
stop chan struct{}
16+
interval time.Duration
17+
reportedError bool
1718
}
1819

1920
var _ Marshaller = &Spinner{}
@@ -27,7 +28,7 @@ func StartSpinner(out Outputer, msg string, interval time.Duration) *Spinner {
2728
if out.Config().Interactive {
2829
frames = []string{`|`, `/`, `-`, `\`}
2930
}
30-
d := &Spinner{0, frames, out, make(chan struct{}, 1), interval}
31+
d := &Spinner{0, frames, out, make(chan struct{}, 1), interval, false}
3132

3233
if msg != "" {
3334
d.out.Fprint(d.out.Config().ErrWriter, strings.TrimSuffix(msg, " ")+" ")
@@ -50,7 +51,11 @@ func (d *Spinner) moveCaretBack() int {
5051
prevPos = len(d.frames) - 1
5152
}
5253
prevFrame := d.frames[prevPos]
53-
d.out.Fprint(d.out.Config().ErrWriter, fmt.Sprintf(moveCaretBack, len(prevFrame)))
54+
if d.out.Config().ShellName != "cmd" { // cannot use subshell/cmd.Name due to import cycle
55+
d.moveCaretBackInTerminal(len(prevFrame))
56+
} else {
57+
d.moveCaretBackInCommandPrompt(len(prevFrame))
58+
}
5459

5560
return len(prevFrame)
5661
}
@@ -96,3 +101,7 @@ func (d *Spinner) Stop(msg string) {
96101

97102
d.out.Fprint(d.out.Config().ErrWriter, "\n")
98103
}
104+
105+
func (d *Spinner) moveCaretBackInTerminal(n int) {
106+
d.out.Fprint(d.out.Config().ErrWriter, fmt.Sprintf(moveCaretBackEscapeSequence, n))
107+
}

internal/output/progress_unix.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//go:build linux || darwin
2+
// +build linux darwin
3+
4+
package output
5+
6+
import (
7+
"github.com/ActiveState/cli/internal/rollbar"
8+
)
9+
10+
func (d *Spinner) moveCaretBackInCommandPrompt(n int) {
11+
if !d.reportedError {
12+
rollbar.Error("Incorrectly detected Windows command prompt in Unix environment")
13+
d.reportedError = true
14+
}
15+
}

internal/output/progress_win.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//go:build windows
2+
// +build windows
3+
4+
package output
5+
6+
import (
7+
"os"
8+
"syscall"
9+
"unsafe"
10+
11+
"github.com/ActiveState/cli/internal/rollbar"
12+
)
13+
14+
var kernel32 = syscall.NewLazyDLL("kernel32.dll")
15+
var procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo")
16+
var procSetConsoleCursorPosition = kernel32.NewProc("SetConsoleCursorPosition")
17+
18+
type coord struct {
19+
x short
20+
y short
21+
}
22+
23+
type short int16
24+
type word uint16
25+
26+
type smallRect struct {
27+
bottom short
28+
left short
29+
right short
30+
top short
31+
}
32+
33+
type consoleScreenBufferInfo struct {
34+
size coord
35+
cursorPosition coord
36+
attributes word
37+
window smallRect
38+
maximumWindowSize coord
39+
}
40+
41+
func (d *Spinner) moveCaretBackInCommandPrompt(n int) {
42+
handle := syscall.Handle(os.Stdout.Fd())
43+
44+
var csbi consoleScreenBufferInfo
45+
if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))); err != nil {
46+
var cursor coord
47+
cursor.x = csbi.cursorPosition.x + short(-n)
48+
cursor.y = csbi.cursorPosition.y
49+
50+
_, _, err2 := procSetConsoleCursorPosition.Call(uintptr(handle), uintptr(*(*int32)(unsafe.Pointer(&cursor))))
51+
if err2 != nil && !d.reportedError {
52+
rollbar.Error("Error calling SetConsoleCursorPosition: %v", err2)
53+
d.reportedError = true
54+
}
55+
} else if !d.reportedError {
56+
rollbar.Error("Error calling GetConsoleScreenBufferInfo: %v", err)
57+
d.reportedError = true
58+
}
59+
}

internal/subshell/subshell.go

Lines changed: 56 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -88,49 +88,34 @@ type SubShell interface {
8888

8989
// New returns the subshell relevant to the current process, but does not activate it
9090
func New(cfg sscommon.Configurable) SubShell {
91-
binary := resolveBinaryPath(DetectShellBinary(cfg))
92-
93-
name := filepath.Base(binary)
94-
name = strings.TrimSuffix(name, filepath.Ext(name))
95-
logging.Debug("Detected SHELL: %s", name)
96-
97-
if runtime.GOOS == "windows" {
98-
// For some reason Go or MSYS doesn't translate paths with spaces correctly, so we have to strip out the
99-
// invalid escape characters for spaces
100-
binary = strings.ReplaceAll(binary, `\ `, ` `)
101-
}
91+
name, path := DetectShell(cfg)
10292

10393
var subs SubShell
10494
switch name {
105-
case "bash":
95+
case bash.Name:
10696
subs = &bash.SubShell{}
107-
case "zsh":
97+
case zsh.Name:
10898
subs = &zsh.SubShell{}
109-
case "tcsh":
99+
case tcsh.Name:
110100
subs = &tcsh.SubShell{}
111-
case "fish":
101+
case fish.Name:
112102
subs = &fish.SubShell{}
113-
case "cmd":
103+
case cmd.Name:
114104
subs = &cmd.SubShell{}
115105
default:
116-
logging.Debug("Unsupported shell: %s, defaulting to OS default.", name)
117-
rollbar.Error("Unsupported shell: %s", name) // we just want to know what this person is using
106+
rollbar.Error("subshell.DetectShell did not return a known name: %s", name)
118107
switch runtime.GOOS {
119108
case "windows":
120-
binary = "cmd.exe"
121109
subs = &cmd.SubShell{}
122110
case "darwin":
123-
binary = "zsh"
124111
subs = &zsh.SubShell{}
125112
default:
126-
binary = "bash"
127113
subs = &bash.SubShell{}
128114
}
129-
binary = resolveBinaryPath(binary)
130115
}
131116

132-
logging.Debug("Using binary: %s", binary)
133-
subs.SetBinary(binary)
117+
logging.Debug("Using binary: %s", path)
118+
subs.SetBinary(path)
134119

135120
env := funk.FilterString(os.Environ(), func(s string) bool {
136121
return !strings.HasPrefix(s, constants.ProjectEnvVarName)
@@ -182,8 +167,10 @@ func ConfigureAvailableShells(shell SubShell, cfg sscommon.Configurable, env map
182167
return nil
183168
}
184169

185-
func DetectShellBinary(cfg sscommon.Configurable) (binary string) {
170+
// DetectShell detects the shell relevant to the current process and returns its name and path.
171+
func DetectShell(cfg sscommon.Configurable) (string, string) {
186172
configured := cfg.GetString(ConfigKeyShell)
173+
var binary string
187174
defer func() {
188175
// do not re-write shell binary to config, if the value did not change.
189176
if configured == binary {
@@ -196,25 +183,56 @@ func DetectShellBinary(cfg sscommon.Configurable) (binary string) {
196183
}
197184
}()
198185

199-
if binary := os.Getenv("SHELL"); binary != "" {
200-
return binary
201-
}
202-
203-
if runtime.GOOS == "windows" {
186+
binary = os.Getenv("SHELL")
187+
if binary == "" && runtime.GOOS == "windows" {
204188
binary = os.Getenv("ComSpec")
205-
if binary != "" {
206-
return binary
207-
}
208189
}
209190

210-
fallback := configured
211-
if fallback == "" {
191+
if binary == "" {
192+
binary = configured
193+
}
194+
if binary == "" {
212195
if runtime.GOOS == "windows" {
213-
fallback = "cmd.exe"
196+
binary = "cmd.exe"
214197
} else {
215-
fallback = "bash"
198+
binary = "bash"
199+
}
200+
}
201+
202+
path := resolveBinaryPath(binary)
203+
204+
name := filepath.Base(path)
205+
name = strings.TrimSuffix(name, filepath.Ext(name))
206+
logging.Debug("Detected SHELL: %s", name)
207+
208+
if runtime.GOOS == "windows" {
209+
// For some reason Go or MSYS doesn't translate paths with spaces correctly, so we have to strip out the
210+
// invalid escape characters for spaces
211+
path = strings.ReplaceAll(path, `\ `, ` `)
212+
}
213+
214+
isKnownShell := false
215+
for _, ssName := range []string{bash.Name, cmd.Name, fish.Name, tcsh.Name, zsh.Name} {
216+
if name == ssName {
217+
isKnownShell = true
218+
break
219+
}
220+
}
221+
if !isKnownShell {
222+
logging.Debug("Unsupported shell: %s, defaulting to OS default.", name)
223+
rollbar.Error("Unsupported shell: %s", name) // we just want to know what this person is using
224+
switch runtime.GOOS {
225+
case "windows":
226+
name = cmd.Name
227+
path = resolveBinaryPath("cmd.exe")
228+
case "darwin":
229+
name = zsh.Name
230+
path = resolveBinaryPath("zsh")
231+
default:
232+
name = bash.Name
233+
path = resolveBinaryPath("bash")
216234
}
217235
}
218236

219-
return fallback
237+
return name, path
220238
}

0 commit comments

Comments
 (0)