From 4fea9255d5eae175e5a3bf9574deb33f28e07c06 Mon Sep 17 00:00:00 2001 From: Vladimir Urushev Date: Wed, 1 Jul 2026 03:01:44 +0200 Subject: [PATCH] fix: detect TTY with real isatty check instead of char-device test --- cmd/devbox/run.go | 3 ++- cmd/devbox/shell.go | 12 ++++------- cmd/devbox/tty_test.go | 47 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 cmd/devbox/tty_test.go diff --git a/cmd/devbox/run.go b/cmd/devbox/run.go index d3dee14..8f6ace0 100644 --- a/cmd/devbox/run.go +++ b/cmd/devbox/run.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "os" "github.com/docker/compose/v5/pkg/api" "github.com/spf13/cobra" @@ -91,7 +92,7 @@ func runRun(ctx context.Context, p *project.Project, command string, args []stri case scenario.Tty != nil: tty = *scenario.Tty default: - tty = isTTYAvailable() + tty = isTTYAvailable(os.Stdin) } opts := project.RunOptions{ diff --git a/cmd/devbox/shell.go b/cmd/devbox/shell.go index be1ca08..9f07bc6 100644 --- a/cmd/devbox/shell.go +++ b/cmd/devbox/shell.go @@ -11,6 +11,7 @@ import ( "github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/client" "github.com/spf13/cobra" + "golang.org/x/term" "github.com/pilat/devbox/internal/project" ) @@ -84,7 +85,7 @@ func runShell(ctx context.Context, p *project.Project, serviceName string, noTty if noTtyFlag { tty = false } else { - tty = isTTYAvailable() + tty = isTTYAvailable(os.Stdin) } opts := project.RunOptions{ @@ -160,11 +161,6 @@ func filterLabels(projectName, serviceName string) client.Filters { Add("label", "com.docker.compose.container-number=1") } -func isTTYAvailable() bool { - stat, err := os.Stdin.Stat() - if err != nil { - return false - } - - return (stat.Mode() & os.ModeCharDevice) != 0 +func isTTYAvailable(f *os.File) bool { + return term.IsTerminal(int(f.Fd())) } diff --git a/cmd/devbox/tty_test.go b/cmd/devbox/tty_test.go new file mode 100644 index 0000000..be312e5 --- /dev/null +++ b/cmd/devbox/tty_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "os" + "testing" +) + +func TestIsTTYAvailable(t *testing.T) { + devNull, err := os.Open(os.DevNull) + if err != nil { + t.Fatalf("failed to open %s: %v", os.DevNull, err) + } + t.Cleanup(func() { devNull.Close() }) + + tmpFile, err := os.CreateTemp(t.TempDir(), "tty") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + t.Cleanup(func() { tmpFile.Close() }) + + pipeR, pipeW, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + t.Cleanup(func() { + pipeR.Close() + pipeW.Close() + }) + + tests := []struct { + name string + file *os.File + want bool + }{ + {name: "dev null is not a terminal", file: devNull, want: false}, + {name: "regular file is not a terminal", file: tmpFile, want: false}, + {name: "pipe is not a terminal", file: pipeR, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isTTYAvailable(tt.file); got != tt.want { + t.Errorf("isTTYAvailable() = %v, want %v", got, tt.want) + } + }) + } +}