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:
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...").
|
|
||||||
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)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...").
|
|
||||||
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)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-13
@@ -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...").
|
|
||||||
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)
|
containers, err = collectContainers(ctx, clusterClient)
|
||||||
return err
|
return err
|
||||||
}).
|
})
|
||||||
Run()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("collect containers: %w", err)
|
return fmt.Errorf("collect containers: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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