diff --git a/.gitignore b/.gitignore index b4b66b69..807b3e80 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ buildkite.yaml .bk.yaml build-logs-* +/bk diff --git a/internal/agent/stoppable.go b/internal/agent/stoppable.go index 6bf323fd..d4dd4387 100644 --- a/internal/agent/stoppable.go +++ b/internal/agent/stoppable.go @@ -109,3 +109,11 @@ func (agent StoppableAgent) View() string { func (agent StoppableAgent) Errored() bool { return agent.err != nil } + +func (agent StoppableAgent) Error() error { + return agent.err +} + +func (agent StoppableAgent) ID() string { + return agent.id +} diff --git a/internal/io/confirm.go b/internal/io/confirm.go index 454cca02..ca72fce2 100644 --- a/internal/io/confirm.go +++ b/internal/io/confirm.go @@ -1,10 +1,24 @@ package io import ( + "os" + "github.com/charmbracelet/huh" + "github.com/mattn/go-isatty" ) func Confirm(confirmed *bool, title string) error { + // If already confirmed via flag, skip the prompt + if *confirmed { + return nil + } + + // If no TTY is available, default to confirmed=true (non-interactive mode) + if !isatty.IsTerminal(os.Stdout.Fd()) { + *confirmed = true + return nil + } + 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..0cdbedb9 --- /dev/null +++ b/internal/io/confirm_test.go @@ -0,0 +1,33 @@ +package io + +import ( + "testing" +) + +func TestConfirmWithoutTTY(t *testing.T) { + // Test that Confirm works without TTY and defaults to true + confirmed := false + err := Confirm(&confirmed, "Test confirmation") + + if err != nil { + t.Errorf("Confirm should not return error: %v", err) + } + + if !confirmed { + t.Error("Confirm should default to true when no TTY is available") + } +} + +func TestConfirmWithFlag(t *testing.T) { + // Test that Confirm respects pre-set flag + confirmed := true + err := Confirm(&confirmed, "Test confirmation") + + if err != nil { + t.Errorf("Confirm should not return error: %v", err) + } + + if !confirmed { + t.Error("Confirm should respect pre-set flag") + } +} diff --git a/internal/io/input.go b/internal/io/input.go index e720cd74..90797e60 100644 --- a/internal/io/input.go +++ b/internal/io/input.go @@ -19,8 +19,11 @@ func HasDataAvailable(reader io.Reader) bool { case *bufio.Reader: return f.Size() > 0 case *strings.Reader: - return f.Size() > 0 + // Check if there are unread bytes remaining + return f.Len() > 0 + default: + // For other reader types, we can't easily determine if data is available + // This is a conservative approach - assume no data is available + return false } - - return false } diff --git a/internal/io/prompt.go b/internal/io/prompt.go index 8f25ae22..40e009fc 100644 --- a/internal/io/prompt.go +++ b/internal/io/prompt.go @@ -1,6 +1,12 @@ package io -import "github.com/charmbracelet/huh" +import ( + "errors" + "os" + + "github.com/charmbracelet/huh" + "github.com/mattn/go-isatty" +) const ( typeOrganizationMessage = "Pick an organization" @@ -10,6 +16,11 @@ const ( // PromptForOne will show the list of options to the user, allowing them to select one to return. // It's possible for them to choose none or cancel the selection, resulting in an error. func PromptForOne(resource string, options []string) (string, error) { + // If no TTY is available, cannot prompt user for selection + if !isatty.IsTerminal(os.Stdout.Fd()) { + return "", errors.New("cannot prompt for selection: no TTY available (use appropriate flags to specify the selection)") + } + var message string switch resource { case "pipeline": diff --git a/internal/io/prompt_test.go b/internal/io/prompt_test.go new file mode 100644 index 00000000..b7923e6e --- /dev/null +++ b/internal/io/prompt_test.go @@ -0,0 +1,19 @@ +package io + +import ( + "testing" +) + +func TestPromptForOneWithoutTTY(t *testing.T) { + // Test that PromptForOne fails gracefully without TTY + _, err := PromptForOne("pipeline", []string{"option1", "option2"}) + + if err == nil { + t.Error("PromptForOne should return error when no TTY is available") + } + + expectedError := "cannot prompt for selection: no TTY available (use appropriate flags to specify the selection)" + if err.Error() != expectedError { + t.Errorf("Expected error message %q, got %q", expectedError, err.Error()) + } +} diff --git a/pkg/cmd/agent/stop.go b/pkg/cmd/agent/stop.go index 989e81c4..188f5f6e 100644 --- a/pkg/cmd/agent/stop.go +++ b/pkg/cmd/agent/stop.go @@ -4,6 +4,8 @@ import ( "bufio" "context" "errors" + "fmt" + "os" "strings" "sync" @@ -12,6 +14,7 @@ import ( bk_io "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" tea "github.com/charmbracelet/bubbletea" + "github.com/mattn/go-isatty" "github.com/spf13/cobra" "golang.org/x/sync/semaphore" ) @@ -53,6 +56,62 @@ func NewCmdAgentStop(f *factory.Factory) *cobra.Command { } func RunStop(cmd *cobra.Command, args []string, opts *AgentStopOptions) error { + // Check TTY availability first, before consuming any input + if !isatty.IsTerminal(os.Stdout.Fd()) { + // Non-TTY mode: run synchronously without interactive UI + return runStopNonInteractive(cmd, args, opts) + } + + // TTY mode: use interactive UI with Bubble Tea + return runStopInteractive(cmd, args, opts) +} + +func runStopNonInteractive(cmd *cobra.Command, args []string, opts *AgentStopOptions) error { + var failed []string + var agentIDs []string + + // Use the same logic as the original: either stdin OR args + if bk_io.HasDataAvailable(cmd.InOrStdin()) { + scanner := bufio.NewScanner(cmd.InOrStdin()) + scanner.Split(bufio.ScanLines) + for scanner.Scan() { + agentID := scanner.Text() + if strings.TrimSpace(agentID) != "" { + agentIDs = append(agentIDs, agentID) + } + } + if scanner.Err() != nil { + return scanner.Err() + } + } else if len(args) > 0 { + agentIDs = args + } else { + return errors.New("must supply agents to stop") + } + + // Process all agent IDs synchronously + for _, agentID := range agentIDs { + if strings.TrimSpace(agentID) != "" { + org, id := parseAgentArg(agentID, opts.f.Config) + _, err := opts.f.RestAPIClient.Agents.Stop(cmd.Context(), org, id, opts.force) + if err != nil { + failed = append(failed, fmt.Sprintf("Failed to stop agent %s: %v", agentID, err)) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Stopped agent %s\n", agentID) + } + } + } + + if len(failed) > 0 { + for _, failure := range failed { + fmt.Fprintf(cmd.OutOrStdout(), "%s\n", failure) + } + return errors.New("at least one agent failed to stop") + } + return nil +} + +func runStopInteractive(cmd *cobra.Command, args []string, opts *AgentStopOptions) error { // use a wait group to ensure we exit the program after all agents have finished var wg sync.WaitGroup // this semaphore is used to limit how many concurrent API requests can be sent @@ -88,6 +147,7 @@ func RunStop(cmd *cobra.Command, args []string, opts *AgentStopOptions) error { return errors.New("must supply agents to stop") } + // TTY is available, use interactive mode with Bubble Tea bulkAgent := agent.BulkAgent{ Agents: agents, } diff --git a/pkg/cmd/cluster/view.go b/pkg/cmd/cluster/view.go index 3171367b..db22bf58 100644 --- a/pkg/cmd/cluster/view.go +++ b/pkg/cmd/cluster/view.go @@ -8,7 +8,7 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/buildkite/cli/v3/internal/io" + bk_io "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" @@ -113,7 +113,7 @@ func NewCmdClusterView(f *factory.Factory) *cobra.Command { } var cluster buildkite.Cluster - spinErr := io.SpinWhile("Loading cluster information", func() { + spinErr := bk_io.SpinWhile("Loading cluster information", func() { cluster, _, err = f.RestAPIClient.Clusters.Get(cmd.Context(), f.Config.OrganizationSlug(), args[0]) }) if spinErr != nil {