diff --git a/go.mod b/go.mod index ade14cbc..99b275db 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,10 @@ require ( github.com/alecthomas/chroma/v2 v2.20.0 github.com/caddyserver/caddy/v2 v2.8.4 github.com/cenkalti/backoff/v4 v4.3.0 + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/bubbletea v1.3.9 github.com/charmbracelet/huh v0.6.0 - github.com/charmbracelet/lipgloss v0.13.0 + github.com/charmbracelet/lipgloss v1.1.0 github.com/compose-spec/compose-go/v2 v2.4.5 github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf github.com/deckarep/golang-set/v2 v2.8.0 @@ -51,7 +53,7 @@ require ( golang.org/x/crypto v0.33.0 golang.org/x/net v0.35.0 golang.org/x/sync v0.11.0 - golang.org/x/sys v0.31.0 + golang.org/x/sys v0.36.0 golang.org/x/term v0.29.0 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 @@ -86,11 +88,11 @@ require ( github.com/catppuccin/go v0.2.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbles v0.20.0 // indirect - github.com/charmbracelet/bubbletea v1.1.1 // indirect - github.com/charmbracelet/x/ansi v0.3.2 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect - github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/cloudflare/cfssl v1.6.4 // indirect github.com/containerd/console v1.0.4 // indirect @@ -221,7 +223,7 @@ require ( github.com/mr-tron/base58 v1.2.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect github.com/multiformats/go-multiaddr v0.13.0 // indirect @@ -282,6 +284,7 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.etcd.io/bbolt v1.3.11 // indirect go.opencensus.io v0.24.0 // indirect diff --git a/go.sum b/go.sum index f3851ef2..527057fa 100644 --- a/go.sum +++ b/go.sum @@ -153,20 +153,24 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY= -github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= +github.com/charmbracelet/bubbletea v1.3.9 h1:OBYdfRo6QnlIcXNmcoI2n1NNS65Nk6kI2L2FO1puS/4= +github.com/charmbracelet/bubbletea v1.3.9/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= -github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= -github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= -github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY= -github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a h1:JMdM89Udp/cOl5tC3MuUJXTPE/nAdU1oyt9jRU44qq8= github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= -github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= @@ -803,8 +807,8 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= -github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= @@ -1102,6 +1106,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1316,8 +1322,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 2ae736a7..da04e351 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,7 +2,6 @@ package cli import ( "context" - "errors" "fmt" "net/netip" "os" @@ -10,7 +9,6 @@ import ( "github.com/docker/cli/cli/streams" "github.com/psviderski/uncloud/internal/cli/config" - "github.com/psviderski/uncloud/internal/fs" "github.com/psviderski/uncloud/internal/machine" "github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/sshexec" @@ -73,8 +71,18 @@ func (cli *CLI) SetCurrentContext(name string) error { // ConnectCluster connects to a cluster using the given context name or the current context if not specified. // If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead. func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client.Client, error) { + return cli.ConnectClusterWithOptions(ctx, contextName, ConnectOptions{ + // Default to showing progress for CLI usage. + ShowProgress: true, + }) +} + +// ConnectClusterWithOptions connects to a cluster using the given context name and options. +// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead. +// Options are useful when using the CLI as a library where you may want to disable visual feedback. +func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, contextName string, opts ConnectOptions) (*client.Client, error) { if cli.conn != nil { - return connectCluster(ctx, *cli.conn) + return ConnectCluster(ctx, *cli.conn, opts) } if len(cli.Config.Contexts) == 0 { @@ -116,38 +124,20 @@ func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client ) } - // TODO: iterate over all connections and try to connect to the cluster using the first successful connection. - conn := cfg.Connections[0] - - c, err := connectCluster(ctx, conn) - if err != nil { - return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err) - } - - return c, nil -} - -func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) { - if conn.SSH != "" { - user, host, port, err := conn.SSH.Parse() - if err != nil { - return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err) + // Try each connection in order until one succeeds. + var lastErr error + for _, conn := range cfg.Connections { + c, err := ConnectCluster(ctx, conn, opts) + if err == nil { + return c, nil } - keyPath := fs.ExpandHomeDir(conn.SSHKeyFile) - - sshConfig := &connector.SSHConnectorConfig{ - User: user, - Host: host, - Port: port, - KeyPath: keyPath, - } - return client.New(ctx, connector.NewSSHConnector(sshConfig)) - } else if conn.TCP != nil && conn.TCP.IsValid() { - return client.New(ctx, connector.NewTCPConnector(*conn.TCP)) + lastErr = err } - return nil, errors.New("connection configuration is invalid") + return nil, fmt.Errorf("failed to connect to cluster context '%s': "+ + "all connections (%d) in the Uncloud config (%s) failed; last error: %w", + contextName, len(cfg.Connections), cli.Config.Path(), lastErr) } type InitClusterOptions struct { diff --git a/internal/cli/config/connection.go b/internal/cli/config/connection.go index 6d798315..a44a5a4f 100644 --- a/internal/cli/config/connection.go +++ b/internal/cli/config/connection.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "net" "net/netip" "strconv" @@ -24,6 +25,15 @@ type MachineConnection struct { PublicKey secret.Secret `yaml:"public_key,omitempty"` } +func (c MachineConnection) String() string { + if c.SSH != "" { + return string(c.SSH) + } else if c.TCP != nil && c.TCP.IsValid() { + return fmt.Sprintf("tcp://%s", c.TCP) + } + return "unknown connection" +} + // SSHDestination represents an SSH destination string in the canonical form of "user@host:port". // The default user "root" and port 22 can be omitted. type SSHDestination string diff --git a/internal/cli/connect.go b/internal/cli/connect.go new file mode 100644 index 00000000..ee0eb722 --- /dev/null +++ b/internal/cli/connect.go @@ -0,0 +1,189 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/psviderski/uncloud/internal/cli/config" + "github.com/psviderski/uncloud/internal/fs" + "github.com/psviderski/uncloud/pkg/client" + "github.com/psviderski/uncloud/pkg/client/connector" +) + +// ConnectOptions configures the behavior of cluster connection attempts. +type ConnectOptions struct { + // Whether to show connection progress spinner if stdout is a terminal or progress logs if not. + ShowProgress bool +} + +func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts ConnectOptions) (*client.Client, error) { + if opts.ShowProgress { + return connectClusterWithProgress(ctx, conn) + } + return connectCluster(ctx, conn) +} + +// connectClusterWithProgress connects to the cluster while displaying a progress spinner. +// If the stdout is not a terminal, it falls back to simple progress logs to stderr. +func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) { + // If stdout is not a terminal, fall back to simple progress logs. + if !IsStdoutTerminal() { + fmt.Fprintln(os.Stderr, "Connecting to", conn.String()) + cli, err := connectCluster(ctx, conn) + if err != nil { + fmt.Fprintln(os.Stderr, "Connection failed:", err) + } else { + fmt.Fprintln(os.Stderr, "Connected to cluster.") + } + return cli, err + } + + // Run the connection TUI model. + p := tea.NewProgram(newConnectModel(ctx, conn)) + model, err := p.Run() + if err != nil { + return nil, fmt.Errorf("run connection TUI: %w", err) + } + + m := model.(connectModel) + return m.result.client, m.result.err +} + +func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) { + if conn.SSH != "" { + user, host, port, err := conn.SSH.Parse() + if err != nil { + return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err) + } + + keyPath := fs.ExpandHomeDir(conn.SSHKeyFile) + + sshConfig := &connector.SSHConnectorConfig{ + User: user, + Host: host, + Port: port, + KeyPath: keyPath, + } + return client.New(ctx, connector.NewSSHConnector(sshConfig)) + } else if conn.TCP != nil && conn.TCP.IsValid() { + return client.New(ctx, connector.NewTCPConnector(*conn.TCP)) + } + + return nil, errors.New("connection configuration is invalid") +} + +// connectModel is a TUI model for connecting to a cluster with a progress spinner. +type connectModel struct { + ctx context.Context + conn config.MachineConnection + spinner spinner.Model + // showSpinner controls whether the spinner is visible (delayed to avoid flashing). + showSpinner bool + // done indicates whether the connection attempt has completed (successfully or with error). + done bool + // result holds the result of the connection attempt. + result connectResultMsg +} + +type connectResultMsg struct { + client *client.Client + err error +} + +// showSpinnerMsg is sent after a delay to show the spinner. +type showSpinnerMsg struct{} + +func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel { + s := spinner.New() + s.Spinner = spinner.MiniDot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // the same yellow as in compose progress + + return connectModel{ + ctx: ctx, + conn: conn, + spinner: s, + } +} + +func (m connectModel) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + m.connect(), + m.delayShowSpinner(), + ) +} + +func (m connectModel) connect() tea.Cmd { + return func() tea.Msg { + cli, err := connectCluster(m.ctx, m.conn) + return connectResultMsg{ + client: cli, + err: err, + } + } +} + +// delayShowSpinner returns a command that sends a message to show the spinner after a delay. +// This avoids flashing the spinner if the connection is fast. +func (m connectModel) delayShowSpinner() tea.Cmd { + return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { + return showSpinnerMsg{} + }) +} + +func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + select { + case <-m.ctx.Done(): + m.result.err = m.ctx.Err() + m.done = true + return m, tea.Quit + default: + } + + switch msg := msg.(type) { + case connectResultMsg: + m.result = msg + m.done = true + return m, tea.Quit + + case showSpinnerMsg: + // Only show spinner if connection hasn't completed yet. + if !m.done { + m.showSpinner = true + } + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + m.result.err = fmt.Errorf("connection cancelled") + m.done = true + return m, tea.Quit + } + } + + return m, nil +} + +func (m connectModel) View() string { + // Don't show anything if done or spinner not yet visible. + if m.done || !m.showSpinner { + return "" + } + + style := lipgloss.NewStyle().Foreground(lipgloss.Color("153")) + return fmt.Sprintf("%s %s\n", + m.spinner.View(), + fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())), + ) +} diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go index 48ce10c9..97a5624d 100644 --- a/internal/cli/prompt.go +++ b/internal/cli/prompt.go @@ -31,3 +31,8 @@ func Confirm() (bool, error) { func IsStdinTerminal() bool { return term.IsTerminal(int(os.Stdin.Fd())) } + +// IsStdoutTerminal checks if the standard output is a terminal (TTY). +func IsStdoutTerminal() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +}