diff --git a/cmd/root.go b/cmd/root.go index 374d1c6..ee1b4cc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,10 +14,36 @@ import ( "github.com/supermodeltools/cli/internal/shards" ) +// termIsTerminal wraps term.IsTerminal so tests can stub the syscall check +// independently of the /dev/tty fallback. +var termIsTerminal = func() bool { + return term.IsTerminal(int(syscall.Stdin)) //nolint:unconvert // syscall.Stdin is uintptr on Windows +} + +// openDevTty opens /dev/tty (the process's controlling terminal). It is a +// package-level var so tests can replace it with a mock. On Windows, +// os.Open("/dev/tty") will fail, which is expected — Windows Console API +// (used by term.IsTerminal above) handles Windows Terminal/PowerShell. +var openDevTty = func() (*os.File, error) { + return os.Open("/dev/tty") +} + // stdinIsTerminal reports whether stdin is connected to an interactive // terminal. Pulled into a var so tests can stub it. +// +// MinTTY (Windows Git Bash) reports a non-terminal stdin fd even though the +// user is at an interactive prompt, so we fall back to opening /dev/tty — the +// POSIX controlling-terminal device. If it opens, stdin is interactive. +// See issue #154. var stdinIsTerminal = func() bool { - return term.IsTerminal(int(syscall.Stdin)) //nolint:unconvert // syscall.Stdin is uintptr on Windows + if termIsTerminal() { + return true + } + if f, err := openDevTty(); err == nil { + f.Close() + return true + } + return false } // rootAction enumerates the three branches the bare `supermodel` command diff --git a/cmd/root_test.go b/cmd/root_test.go index bceac9a..3a20d09 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,6 +1,43 @@ package cmd -import "testing" +import ( + "os" + "testing" +) + +// TestStdinIsTerminalUsesDevTtyFallback verifies that stdinIsTerminal falls +// back to opening /dev/tty when term.IsTerminal returns false (e.g. in CI or +// MinTTY/Git Bash on Windows). The fix is tracked in issue #154. +func TestStdinIsTerminalUsesDevTtyFallback(t *testing.T) { + // Force termIsTerminal to return false so the test is deterministic + // regardless of whether the test process itself has an interactive stdin. + origTermIsTerminal := termIsTerminal + t.Cleanup(func() { termIsTerminal = origTermIsTerminal }) + termIsTerminal = func() bool { return false } + + // Save and restore the real openDevTty hook. + orig := openDevTty + t.Cleanup(func() { openDevTty = orig }) + + called := false + openDevTty = func() (*os.File, error) { + called = true + // Return a real, harmless file so the caller can call f.Close(). + return os.Open(os.DevNull) + } + + // With termIsTerminal stubbed to false, stdinIsTerminal must call + // openDevTty as a fallback and return true because our mock succeeds. + got := stdinIsTerminal() + + if !called { + t.Error("stdinIsTerminal did not call openDevTty — /dev/tty fallback is missing (fix #154)") + } + // If openDevTty was called and succeeded, the result must be true. + if called && !got { + t.Error("openDevTty returned a file but stdinIsTerminal returned false — fix #154") + } +} func TestPickRootAction(t *testing.T) { cases := []struct {