diff --git a/cmd/uncloud/context/use.go b/cmd/uncloud/context/use.go index 1bf9f484..cf66c255 100644 --- a/cmd/uncloud/context/use.go +++ b/cmd/uncloud/context/use.go @@ -8,6 +8,7 @@ import ( "charm.land/huh/v2" "github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli/completion" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/spf13/cobra" ) @@ -50,6 +51,10 @@ func selectContext(uncli *cli.CLI) error { if len(uncli.Config.Contexts) == 0 { 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)) diff --git a/cmd/uncloud/deploy.go b/cmd/uncloud/deploy.go index 36927a3f..02595240 100644 --- a/cmd/uncloud/deploy.go +++ b/cmd/uncloud/deploy.go @@ -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. if !opts.yes { - if !tui.IsStdinTerminal() { + if !tui.IsTerminalAvailable() { 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") } diff --git a/cmd/uncloud/machine/add.go b/cmd/uncloud/machine/add.go index b6ec515a..6f24d36a 100644 --- a/cmd/uncloud/machine/add.go +++ b/cmd/uncloud/machine/add.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "charm.land/huh/v2/spinner" "charm.land/lipgloss/v2" "github.com/docker/compose/v2/pkg/progress" "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. - err = spinner.New(). - Title(" Waiting for the machine to join the cluster..."). - 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() + err = tui.RunSpinner(ctx, "Waiting for the machine to join the cluster...", func(ctx context.Context) error { + return machineClient.WaitClusterReady(ctx, 5*time.Minute) + }) if err != nil { return fmt.Errorf("wait for machine to join the cluster: %w", err) } diff --git a/cmd/uncloud/machine/init.go b/cmd/uncloud/machine/init.go index 3fd25127..b73a7c0a 100644 --- a/cmd/uncloud/machine/init.go +++ b/cmd/uncloud/machine/init.go @@ -7,13 +7,12 @@ import ( "strings" "time" - "charm.land/huh/v2/spinner" - "charm.land/lipgloss/v2" "github.com/docker/compose/v2/pkg/progress" "github.com/psviderski/uncloud/cmd/uncloud/caddy" "github.com/psviderski/uncloud/cmd/uncloud/dns" "github.com/psviderski/uncloud/internal/cli" "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/cluster" "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, // 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. - err = spinner.New(). - Title(" Waiting for the cluster to be ready..."). - 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() + err = tui.RunSpinner(ctx, "Waiting for the cluster to be ready...", func(ctx context.Context) error { + return client.WaitClusterReady(ctx, 1*time.Minute) + }) if err != nil { return fmt.Errorf("wait for cluster to be ready: %w", err) } diff --git a/cmd/uncloud/ps.go b/cmd/uncloud/ps.go index e5196bde..5d5927b4 100644 --- a/cmd/uncloud/ps.go +++ b/cmd/uncloud/ps.go @@ -6,7 +6,6 @@ import ( "sort" "time" - "charm.land/huh/v2/spinner" "charm.land/lipgloss/v2" "github.com/docker/docker/api/types/container" "github.com/docker/go-units" @@ -83,20 +82,10 @@ func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error { defer clusterClient.Close() var containers []containerInfo - err = spinner.New(). - Title(" Collecting container info..."). - 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 { - containers, err = collectContainers(ctx, clusterClient) - return err - }). - Run() + err = tui.RunSpinner(ctx, "Collecting container info...", func(ctx context.Context) error { + containers, err = collectContainers(ctx, clusterClient) + return err + }) if err != nil { return fmt.Errorf("collect containers: %w", err) } diff --git a/cmd/uncloud/service/scale.go b/cmd/uncloud/service/scale.go index 1029fa1b..e67a3168 100644 --- a/cmd/uncloud/service/scale.go +++ b/cmd/uncloud/service/scale.go @@ -136,7 +136,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error { // Ask for confirmation unless auto-confirmed with --yes. if !opts.yes { - if !tui.IsStdinTerminal() { + if !tui.IsTerminalAvailable() { 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") } diff --git a/internal/cli/connect.go b/internal/cli/connect.go index 117a1702..59151c31 100644 --- a/internal/cli/connect.go +++ b/internal/cli/connect.go @@ -31,10 +31,9 @@ func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts Con } // 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) { - // If stdout is not a terminal, fall back to simple progress logs. - if !tui.IsStdoutTerminal() { + if !tui.IsTerminalAvailable() { fmt.Fprintln(os.Stderr, "Connecting to", conn.String()) cli, err := connectCluster(ctx, conn) if err != nil { @@ -45,8 +44,8 @@ func connectClusterWithProgress(ctx context.Context, conn config.MachineConnecti return cli, err } - // Run the connection TUI model. - p := tea.NewProgram(newConnectModel(ctx, conn)) + // Run the connection TUI model. Render to stderr so stdout stays clean for command output. + p := tea.NewProgram(newConnectModel(ctx, conn), tea.WithOutput(os.Stderr)) model, err := p.Run() if err != nil { return nil, fmt.Errorf("run connection TUI: %w", err) diff --git a/internal/cli/machine.go b/internal/cli/machine.go index 3e059930..8130d614 100644 --- a/internal/cli/machine.go +++ b/internal/cli/machine.go @@ -99,14 +99,14 @@ func provisionMachine(ctx context.Context, exec sshexec.Executor, version string } func promptResetMachine() error { - if !tui.IsStdinTerminal() { + if !tui.IsTerminalAvailable() { return errors.New("the remote machine is already initialised as a cluster member; " + "cannot ask to confirm reset in non-interactive mode, " + "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" + - "- Remove all service containers from the machine\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"+ "- Reset the Uncloud daemon on the machine to the uninitialised state")) var confirm bool @@ -119,7 +119,9 @@ func promptResetMachine() error { Value(&confirm), ), ).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 { return fmt.Errorf("prompt user to confirm: %w", err) } diff --git a/internal/cli/tui/prompt.go b/internal/cli/tui/prompt.go index cab3af9a..14c674a4 100644 --- a/internal/cli/tui/prompt.go +++ b/internal/cli/tui/prompt.go @@ -25,7 +25,9 @@ func Confirm(title string) (bool, error) { Value(&confirmed), ), ).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 { 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). func IsStdinTerminal() bool { return term.IsTerminal(int(os.Stdin.Fd())) @@ -61,6 +71,11 @@ func IsStdoutTerminal() bool { 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. // Returns 0 if stdout is not a terminal or the width cannot be determined. func TerminalWidth() int { diff --git a/internal/cli/tui/spinner.go b/internal/cli/tui/spinner.go new file mode 100644 index 00000000..10ec79f3 --- /dev/null +++ b/internal/cli/tui/spinner.go @@ -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() +}