mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
cli: do not use interactive TUI elements when control terminal (tty) is not available (fixes #386)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user