Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist/
buildkite.yaml
.bk.yaml
build-logs-*
/bk
8 changes: 8 additions & 0 deletions internal/agent/stoppable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
14 changes: 14 additions & 0 deletions internal/io/confirm.go
Original file line number Diff line number Diff line change
@@ -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().
Expand Down
33 changes: 33 additions & 0 deletions internal/io/confirm_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
9 changes: 6 additions & 3 deletions internal/io/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
13 changes: 12 additions & 1 deletion internal/io/prompt.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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":
Expand Down
19 changes: 19 additions & 0 deletions internal/io/prompt_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
60 changes: 60 additions & 0 deletions pkg/cmd/agent/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"bufio"
"context"
"errors"
"fmt"
"os"
"strings"
"sync"

Expand All @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/cmd/cluster/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down