cli: do not use interactive TUI elements when control terminal (tty) is not available (fixes #386)

This commit is contained in:
Pasha Sviderski
2026-06-18 18:22:48 +10:00
parent 7bb3cb9682
commit 2f717a10a7
10 changed files with 81 additions and 56 deletions
+5
View File
@@ -8,6 +8,7 @@ import (
"charm.land/huh/v2" "charm.land/huh/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -50,6 +51,10 @@ func selectContext(uncli *cli.CLI) error {
if len(uncli.Config.Contexts) == 0 { if len(uncli.Config.Contexts) == 0 {
return fmt.Errorf("no contexts found in Uncloud config (%s)", uncli.Config.Path()) return fmt.Errorf("no contexts found in Uncloud config (%s)", uncli.Config.Path())
} }
if !tui.IsTerminalAvailable() {
return fmt.Errorf("cannot select a context interactively without a terminal. " +
"Pass the context name explicitly: uc ctx use CONTEXT")
}
contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts)) contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts))
+1 -1
View File
@@ -203,7 +203,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
// Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes. // Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes.
if !opts.yes { if !opts.yes {
if !tui.IsStdinTerminal() { if !tui.IsTerminalAvailable() {
return errors.New("cannot ask to confirm deployment plan in non-interactive mode, " + return errors.New("cannot ask to confirm deployment plan in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm") "use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
} }
+3 -14
View File
@@ -8,7 +8,6 @@ import (
"strings" "strings"
"time" "time"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy" "github.com/psviderski/uncloud/cmd/uncloud/caddy"
@@ -173,19 +172,9 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
} }
// Wait for the cluster to be initialised on the machine to be able to deploy the Caddy service. // Wait for the cluster to be initialised on the machine to be able to deploy the Caddy service.
err = spinner.New(). err = tui.RunSpinner(ctx, "Waiting for the machine to join the cluster...", func(ctx context.Context) error {
Title(" Waiting for the machine to join the cluster..."). return machineClient.WaitClusterReady(ctx, 5*time.Minute)
Type(spinner.MiniDot). })
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error {
return machineClient.WaitClusterReady(ctx, 5*time.Minute)
}).
Run()
if err != nil { if err != nil {
return fmt.Errorf("wait for machine to join the cluster: %w", err) return fmt.Errorf("wait for machine to join the cluster: %w", err)
} }
+4 -15
View File
@@ -7,13 +7,12 @@ import (
"strings" "strings"
"time" "time"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy" "github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/cmd/uncloud/dns" "github.com/psviderski/uncloud/cmd/uncloud/dns"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config" "github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/cluster" "github.com/psviderski/uncloud/internal/machine/cluster"
"github.com/psviderski/uncloud/internal/machine/network" "github.com/psviderski/uncloud/internal/machine/network"
@@ -225,19 +224,9 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
// Since the cluster API needs a few moments to become ready after cluster initialisation, // Since the cluster API needs a few moments to become ready after cluster initialisation,
// we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested // we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested
// as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init. // as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init.
err = spinner.New(). err = tui.RunSpinner(ctx, "Waiting for the cluster to be ready...", func(ctx context.Context) error {
Title(" Waiting for the cluster to be ready..."). return client.WaitClusterReady(ctx, 1*time.Minute)
Type(spinner.MiniDot). })
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error {
return client.WaitClusterReady(ctx, 1*time.Minute)
}).
Run()
if err != nil { if err != nil {
return fmt.Errorf("wait for cluster to be ready: %w", err) return fmt.Errorf("wait for cluster to be ready: %w", err)
} }
+4 -15
View File
@@ -6,7 +6,6 @@ import (
"sort" "sort"
"time" "time"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/go-units" "github.com/docker/go-units"
@@ -83,20 +82,10 @@ func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
defer clusterClient.Close() defer clusterClient.Close()
var containers []containerInfo var containers []containerInfo
err = spinner.New(). err = tui.RunSpinner(ctx, "Collecting container info...", func(ctx context.Context) error {
Title(" Collecting container info..."). containers, err = collectContainers(ctx, clusterClient)
Type(spinner.MiniDot). return err
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles { })
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error {
containers, err = collectContainers(ctx, clusterClient)
return err
}).
Run()
if err != nil { if err != nil {
return fmt.Errorf("collect containers: %w", err) return fmt.Errorf("collect containers: %w", err)
} }
+1 -1
View File
@@ -136,7 +136,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
// Ask for confirmation unless auto-confirmed with --yes. // Ask for confirmation unless auto-confirmed with --yes.
if !opts.yes { if !opts.yes {
if !tui.IsStdinTerminal() { if !tui.IsTerminalAvailable() {
return errors.New("cannot ask to confirm scaling plan in non-interactive mode, " + return errors.New("cannot ask to confirm scaling plan in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm") "use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
} }
+4 -5
View File
@@ -31,10 +31,9 @@ func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts Con
} }
// connectClusterWithProgress connects to the cluster while displaying a progress spinner. // 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. // If a terminal is not available, it falls back to simple progress logs to stderr.
func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) { func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
// If stdout is not a terminal, fall back to simple progress logs. if !tui.IsTerminalAvailable() {
if !tui.IsStdoutTerminal() {
fmt.Fprintln(os.Stderr, "Connecting to", conn.String()) fmt.Fprintln(os.Stderr, "Connecting to", conn.String())
cli, err := connectCluster(ctx, conn) cli, err := connectCluster(ctx, conn)
if err != nil { if err != nil {
@@ -45,8 +44,8 @@ func connectClusterWithProgress(ctx context.Context, conn config.MachineConnecti
return cli, err return cli, err
} }
// Run the connection TUI model. // Run the connection TUI model. Render to stderr so stdout stays clean for command output.
p := tea.NewProgram(newConnectModel(ctx, conn)) p := tea.NewProgram(newConnectModel(ctx, conn), tea.WithOutput(os.Stderr))
model, err := p.Run() model, err := p.Run()
if err != nil { if err != nil {
return nil, fmt.Errorf("run connection TUI: %w", err) return nil, fmt.Errorf("run connection TUI: %w", err)
+6 -4
View File
@@ -99,14 +99,14 @@ func provisionMachine(ctx context.Context, exec sshexec.Executor, version string
} }
func promptResetMachine() error { func promptResetMachine() error {
if !tui.IsStdinTerminal() { if !tui.IsTerminalAvailable() {
return errors.New("the remote machine is already initialised as a cluster member; " + return errors.New("the remote machine is already initialised as a cluster member; " +
"cannot ask to confirm reset in non-interactive mode, " + "cannot ask to confirm reset in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm") "use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
} }
fmt.Println(tui.Red.Render("The remote machine is already initialised as a cluster member. Resetting it will:\n" + fmt.Fprintln(os.Stderr, tui.Red.Render("The remote machine is already initialised as a cluster member. Resetting it will:\n"+
"- Remove all service containers from the machine\n" + "- Remove all service containers from the machine\n"+
"- Reset the Uncloud daemon on the machine to the uninitialised state")) "- Reset the Uncloud daemon on the machine to the uninitialised state"))
var confirm bool var confirm bool
@@ -119,7 +119,9 @@ func promptResetMachine() error {
Value(&confirm), Value(&confirm),
), ),
).WithTheme(tui.ThemeConfirmDanger()). ).WithTheme(tui.ThemeConfirmDanger()).
WithAccessible(true) WithAccessible(true).
// Render to stderr so stdout stays clean for command output.
WithOutput(os.Stderr)
if err := form.Run(); err != nil { if err := form.Run(); err != nil {
return fmt.Errorf("prompt user to confirm: %w", err) return fmt.Errorf("prompt user to confirm: %w", err)
} }
+16 -1
View File
@@ -25,7 +25,9 @@ func Confirm(title string) (bool, error) {
Value(&confirmed), Value(&confirmed),
), ),
).WithTheme(ThemeConfirm()). ).WithTheme(ThemeConfirm()).
WithAccessible(true) WithAccessible(true).
// Render to stderr so stdout stays clean for command output.
WithOutput(os.Stderr)
if err := form.Run(); err != nil { if err := form.Run(); err != nil {
return false, err return false, err
} }
@@ -51,6 +53,14 @@ func ThemeConfirmDanger() huh.Theme {
}) })
} }
// IsTerminalAvailable reports whether the control terminal (TTY) is available so an interactive TUI can run.
func IsTerminalAvailable() bool {
// Bubbletea interactive programs read keyboard input from stdin and render to stdout (default) or stderr (uncloud),
// so both must be terminals. In particular, bubbletea falls back to opening /dev/tty when stdin is not a terminal,
// which fails when there is no controlling terminal. See https://github.com/psviderski/uncloud/issues/386
return IsStdinTerminal() && IsStderrTerminal()
}
// IsStdinTerminal checks if the standard input is a terminal (TTY). // IsStdinTerminal checks if the standard input is a terminal (TTY).
func IsStdinTerminal() bool { func IsStdinTerminal() bool {
return term.IsTerminal(int(os.Stdin.Fd())) return term.IsTerminal(int(os.Stdin.Fd()))
@@ -61,6 +71,11 @@ func IsStdoutTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd())) return term.IsTerminal(int(os.Stdout.Fd()))
} }
// IsStderrTerminal checks if the standard error is a terminal (TTY).
func IsStderrTerminal() bool {
return term.IsTerminal(int(os.Stderr.Fd()))
}
// TerminalWidth returns the width of the terminal. // TerminalWidth returns the width of the terminal.
// Returns 0 if stdout is not a terminal or the width cannot be determined. // Returns 0 if stdout is not a terminal or the width cannot be determined.
func TerminalWidth() int { func TerminalWidth() int {
+37
View File
@@ -0,0 +1,37 @@
package tui
import (
"context"
"fmt"
"os"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
)
// RunSpinner shows an animated spinner that prints title while running action.
// When the terminal (TTY) is not available, it prints the title as plain text.
// It renders to stderr so stdout stays clean for command output.
func RunSpinner(ctx context.Context, title string, action func(ctx context.Context) error) error {
// Fall back to plain text when a terminal is not available to avoid the bubbletea /dev/tty error and to keep
// escape codes out of redirected output.
if !IsTerminalAvailable() {
fmt.Fprintln(os.Stderr, title)
return action(ctx)
}
return spinner.New().
// Leading space offsets the title from the spinner glyph.
Title(" " + title).
Type(spinner.MiniDot).
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
WithOutput(os.Stderr).
Context(ctx).
ActionWithErr(action).
Run()
}