diff --git a/internal/io/confirm.go b/internal/io/confirm.go index 4af53517..68af512a 100644 --- a/internal/io/confirm.go +++ b/internal/io/confirm.go @@ -1,14 +1,23 @@ package io import ( + "fmt" + "github.com/charmbracelet/huh" ) func Confirm(confirmed *bool, title string) error { + // If already confirmed via flag, skip the prompt if *confirmed { return nil } + // In non-TTY environments, fail by default with yes flag message + if !IsTerminal() { + return fmt.Errorf("confirmation required but not running in a terminal; use -y or --yes to confirm") + } + + form := huh.NewForm( huh.NewGroup( huh.NewConfirm(). diff --git a/internal/io/confirm_test.go b/internal/io/confirm_test.go new file mode 100644 index 00000000..b6680e6a --- /dev/null +++ b/internal/io/confirm_test.go @@ -0,0 +1,75 @@ +package io + +import ( + "os" + "testing" +) + +func TestConfirm(t *testing.T) { + tests := []struct { + name string + confirmed bool + isTTY bool + expectError bool + errorMsg string + }{ + { + name: "already confirmed via flag", + confirmed: true, + isTTY: false, + expectError: false, + }, + { + name: "not confirmed and not TTY", + confirmed: false, + isTTY: false, + expectError: true, + errorMsg: "confirmation required but not running in a terminal; use -y or --yes to confirm", + }, + { + name: "not confirmed and TTY (interactive test skipped)", + confirmed: false, + isTTY: true, + expectError: false, // We'll skip this test as it requires user interaction + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.name == "not confirmed and TTY (interactive test skipped)" { + t.Skip("Skipping interactive test") + } + + // Mock TTY detection by temporarily redirecting stdout + if !tt.isTTY { + // Create a pipe to simulate non-TTY + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + + // Temporarily replace stdout + oldStdout := os.Stdout + os.Stdout = w + defer func() { + os.Stdout = oldStdout + }() + } + + confirmed := tt.confirmed + err := Confirm(&confirmed, "Test confirmation") + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if err.Error() != tt.errorMsg { + t.Errorf("Expected error message %q but got %q", tt.errorMsg, err.Error()) + } + } else if err != nil { + t.Errorf("Unexpected error: %v", err) + } + }) + } +} diff --git a/internal/io/spinner.go b/internal/io/spinner.go index 565d90cc..29999973 100644 --- a/internal/io/spinner.go +++ b/internal/io/spinner.go @@ -1,14 +1,11 @@ package io import ( - "os" - "github.com/charmbracelet/huh/spinner" - "github.com/mattn/go-isatty" ) func SpinWhile(name string, action func()) error { - if !isatty.IsTerminal(os.Stdout.Fd()) { + if !IsTerminal() { // No TTY available, just run the action without spinner action() return nil diff --git a/internal/io/spinner_test.go b/internal/io/spinner_test.go index 4a37613a..3ba3acaf 100644 --- a/internal/io/spinner_test.go +++ b/internal/io/spinner_test.go @@ -1,10 +1,7 @@ package io import ( - "os" "testing" - - "github.com/mattn/go-isatty" ) func TestSpinWhileWithoutTTY(t *testing.T) { @@ -59,7 +56,7 @@ func TestSpinWhileWithError(t *testing.T) { func TestSpinWhileTTYDetection(t *testing.T) { // Test that TTY detection works as expected // This test documents the behavior rather than forcing specific outcomes - isTTY := isatty.IsTerminal(os.Stdout.Fd()) + isTTY := IsTerminal() actionCalled := false err := SpinWhile("TTY detection test", func() { diff --git a/internal/io/tty.go b/internal/io/tty.go new file mode 100644 index 00000000..5b8c1fbe --- /dev/null +++ b/internal/io/tty.go @@ -0,0 +1,12 @@ +package io + +import ( + "os" + + "github.com/mattn/go-isatty" +) + +// IsTerminal returns true if stdout is a terminal +func IsTerminal() bool { + return isatty.IsTerminal(os.Stdout.Fd()) +}