From 68661e57c379213f0bbceefb1e58bc94b2f4fa68 Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Wed, 18 Mar 2026 16:25:25 +1000 Subject: [PATCH] refactor: tui package for CLI styles and prompts, restyle confirmation --- cmd/uncloud/caddy/deploy.go | 3 ++- cmd/uncloud/machine/add.go | 3 ++- cmd/uncloud/machine/rm.go | 5 ++-- cmd/uncloud/ps.go | 3 ++- cmd/uncloud/service/exec.go | 3 ++- cmd/uncloud/service/logs.go | 3 ++- cmd/uncloud/volume/rm.go | 3 ++- internal/cli/cli.go | 12 +++++++-- internal/cli/connect.go | 3 ++- internal/cli/machine.go | 3 ++- internal/cli/prompt.go | 38 -------------------------- internal/cli/tui/print.go | 11 ++++++++ internal/cli/tui/prompt.go | 53 +++++++++++++++++++++++++++++++++++++ internal/cli/tui/style.go | 17 ++++++++++++ pkg/client/print.go | 14 ---------- pkg/client/volume.go | 3 ++- 16 files changed, 112 insertions(+), 65 deletions(-) delete mode 100644 internal/cli/prompt.go create mode 100644 internal/cli/tui/print.go create mode 100644 internal/cli/tui/prompt.go create mode 100644 internal/cli/tui/style.go delete mode 100644 pkg/client/print.go diff --git a/cmd/uncloud/caddy/deploy.go b/cmd/uncloud/caddy/deploy.go index 05559a25..7bd37359 100644 --- a/cmd/uncloud/caddy/deploy.go +++ b/cmd/uncloud/caddy/deploy.go @@ -12,6 +12,7 @@ import ( "github.com/docker/cli/cli/streams" "github.com/docker/compose/v2/pkg/progress" "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client" @@ -135,7 +136,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { fmt.Println(plan.Format(resolver)) fmt.Println() - confirmed, err := cli.Confirm() + confirmed, err := tui.Confirm("") if err != nil { return fmt.Errorf("confirm deployment: %w", err) } diff --git a/cmd/uncloud/machine/add.go b/cmd/uncloud/machine/add.go index fc04c866..20664555 100644 --- a/cmd/uncloud/machine/add.go +++ b/cmd/uncloud/machine/add.go @@ -14,6 +14,7 @@ import ( "github.com/psviderski/uncloud/cmd/uncloud/caddy" "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/network" "github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client" @@ -224,7 +225,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, fmt.Println() if !opts.yes { - confirmed, err := cli.Confirm() + confirmed, err := tui.Confirm("") if err != nil { return fmt.Errorf("confirm deployment: %w", err) } diff --git a/cmd/uncloud/machine/rm.go b/cmd/uncloud/machine/rm.go index ebdb0096..82c98c5c 100644 --- a/cmd/uncloud/machine/rm.go +++ b/cmd/uncloud/machine/rm.go @@ -14,6 +14,7 @@ import ( "github.com/docker/compose/v2/pkg/progress" "github.com/docker/docker/api/types/container" "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/pkg/api" "github.com/spf13/cobra" @@ -118,7 +119,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt } if !opts.yes { - confirmed, err := cli.Confirm() + confirmed, err := tui.Confirm("") if err != nil { return fmt.Errorf("confirm removal: %w", err) } @@ -154,7 +155,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt // Remove the connection to the machine from the uncloud config if it exists. if uncli.Config != nil { - contextName := uncli.GetContextOverrideOrCurrent() + contextName := uncli.ContextOverrideOrCurrent() if context, ok := uncli.Config.Contexts[contextName]; ok { for i, c := range context.Connections { if c.MachineID == m.Id { diff --git a/cmd/uncloud/ps.go b/cmd/uncloud/ps.go index 1054b0c8..6154724a 100644 --- a/cmd/uncloud/ps.go +++ b/cmd/uncloud/ps.go @@ -11,6 +11,7 @@ import ( "charm.land/lipgloss/v2/table" "github.com/docker/docker/api/types/container" "github.com/docker/go-units" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/spf13/cobra" "github.com/psviderski/uncloud/internal/cli" @@ -231,7 +232,7 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo } if msc.Metadata != nil && msc.Metadata.Error != "" { - client.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName, + tui.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName, msc.Metadata.Error)) continue } diff --git a/cmd/uncloud/service/exec.go b/cmd/uncloud/service/exec.go index 2712b1ca..445627f3 100644 --- a/cmd/uncloud/service/exec.go +++ b/cmd/uncloud/service/exec.go @@ -7,6 +7,7 @@ import ( "github.com/docker/cli/cli/streams" "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" "github.com/spf13/cobra" ) @@ -83,7 +84,7 @@ If the service has multiple replicas and no container ID is specified, the comma func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error { // Disable TTY allocation if not connected to a terminal - if !cli.IsStdoutTerminal() { + if !tui.IsStdoutTerminal() { opts.noTty = true } diff --git a/cmd/uncloud/service/logs.go b/cmd/uncloud/service/logs.go index cd47b2c4..e205fe3d 100644 --- a/cmd/uncloud/service/logs.go +++ b/cmd/uncloud/service/logs.go @@ -15,6 +15,7 @@ import ( mapset "github.com/deckarep/golang-set/v2" "github.com/docker/docker/pkg/stringid" "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client/compose" @@ -171,7 +172,7 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo serviceNames = foundServices for _, name := range notFoundServices { - client.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name)) + tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name)) } } diff --git a/cmd/uncloud/volume/rm.go b/cmd/uncloud/volume/rm.go index dd31e9e8..2e2d2fcd 100644 --- a/cmd/uncloud/volume/rm.go +++ b/cmd/uncloud/volume/rm.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" "github.com/spf13/cobra" ) @@ -79,7 +80,7 @@ func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOpti } fmt.Println() - confirmed, err := cli.Confirm() + confirmed, err := tui.Confirm("") if err != nil { return fmt.Errorf("confirm removal: %w", err) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e184a98b..e683e292 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -71,7 +71,7 @@ func (cli *CLI) SetCurrentContext(name string) error { return cli.Config.Save() } -func (cli *CLI) GetContextOverrideOrCurrent() string { +func (cli *CLI) ContextOverrideOrCurrent() string { if cli.contextOverride != "" { return cli.contextOverride } @@ -81,6 +81,14 @@ func (cli *CLI) GetContextOverrideOrCurrent() string { return "" } +// DirectConnection returns the connection string if --connect or UNCLOUD_CONNECT was specified. +func (cli *CLI) DirectConnection() string { + if cli.conn != nil { + return cli.conn.String() + } + return "" +} + // ConnectCluster connects to a cluster using the context override 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) (*client.Client, error) { @@ -316,7 +324,7 @@ type AddMachineOptions struct { // cluster. The machine client is connected to the new machine and can be used to interact with it. // Both client should be closed after use by the caller. func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, *client.Client, error) { - contextName := cli.GetContextOverrideOrCurrent() + contextName := cli.ContextOverrideOrCurrent() c, err := cli.ConnectCluster(ctx) if err != nil { return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err) diff --git a/internal/cli/connect.go b/internal/cli/connect.go index f75cf47c..87d4e707 100644 --- a/internal/cli/connect.go +++ b/internal/cli/connect.go @@ -11,6 +11,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/psviderski/uncloud/internal/cli/config" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/fs" "github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client/connector" @@ -33,7 +34,7 @@ func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts Con // 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() { + if !tui.IsStdoutTerminal() { fmt.Fprintln(os.Stderr, "Connecting to", conn.String()) cli, err := connectCluster(ctx, conn) if err != nil { diff --git a/internal/cli/machine.go b/internal/cli/machine.go index 58923ea0..fe690304 100644 --- a/internal/cli/machine.go +++ b/internal/cli/machine.go @@ -10,6 +10,7 @@ import ( "charm.land/huh/v2" "github.com/cenkalti/backoff/v4" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/sshexec" "google.golang.org/protobuf/types/known/emptypb" @@ -88,7 +89,7 @@ func provisionMachine(ctx context.Context, exec sshexec.Executor, version string } func promptResetMachine() error { - if !IsStdinTerminal() { + if !tui.IsStdinTerminal() { 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") diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go deleted file mode 100644 index 1ca0c798..00000000 --- a/internal/cli/prompt.go +++ /dev/null @@ -1,38 +0,0 @@ -package cli - -import ( - "os" - - "charm.land/huh/v2" - "golang.org/x/term" -) - -func Confirm() (bool, error) { - var confirmed bool - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title( - "Do you want to continue?", - ). - Affirmative("Yes!"). - Negative("No"). - Value(&confirmed), - ), - ).WithAccessible(true) - if err := form.Run(); err != nil { - return false, err - } - - return confirmed, nil -} - -// IsStdinTerminal checks if the standard input is a terminal (TTY). -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())) -} diff --git a/internal/cli/tui/print.go b/internal/cli/tui/print.go new file mode 100644 index 00000000..36a8851b --- /dev/null +++ b/internal/cli/tui/print.go @@ -0,0 +1,11 @@ +package tui + +import ( + "fmt" + "os" +) + +func PrintWarning(msg string) { + styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg)) + fmt.Fprintln(os.Stderr, styledMsg) +} diff --git a/internal/cli/tui/prompt.go b/internal/cli/tui/prompt.go new file mode 100644 index 00000000..9d741595 --- /dev/null +++ b/internal/cli/tui/prompt.go @@ -0,0 +1,53 @@ +package tui + +import ( + "os" + + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "golang.org/x/term" +) + +// Confirm shows a confirmation prompt with a yellow-styled title. +// If title is empty, it defaults to "Do you want to continue?". +func Confirm(title string) (bool, error) { + if title == "" { + title = "Do you want to continue?" + } + + var confirmed bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(title). + Affirmative("Yes!"). + Negative("No"). + Value(&confirmed), + ), + ).WithTheme(ThemeConfirm()). + WithAccessible(true) + if err := form.Run(); err != nil { + return false, err + } + + return confirmed, nil +} + +// ThemeConfirm returns a huh theme with a bold yellow title style for the confirmation prompt. +func ThemeConfirm() huh.Theme { + return huh.ThemeFunc(func(isDark bool) *huh.Styles { + t := huh.ThemeBase(isDark) + t.Focused.Title = t.Focused.Title.Foreground(lipgloss.Yellow).Bold(true) + return t + }) +} + +// IsStdinTerminal checks if the standard input is a terminal (TTY). +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())) +} diff --git a/internal/cli/tui/style.go b/internal/cli/tui/style.go new file mode 100644 index 00000000..0959e38c --- /dev/null +++ b/internal/cli/tui/style.go @@ -0,0 +1,17 @@ +package tui + +import "charm.land/lipgloss/v2" + +var ( + Faint = lipgloss.NewStyle().Faint(true) + Red = lipgloss.NewStyle().Foreground(lipgloss.Red) + Green = lipgloss.NewStyle().Foreground(lipgloss.Green) + Yellow = lipgloss.NewStyle().Foreground(lipgloss.Yellow) + + Bold = lipgloss.NewStyle().Bold(true) + BoldRed = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Red) + BoldGreen = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Green) + BoldYellow = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Yellow) + + NameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("152")) +) diff --git a/pkg/client/print.go b/pkg/client/print.go deleted file mode 100644 index f8b81bac..00000000 --- a/pkg/client/print.go +++ /dev/null @@ -1,14 +0,0 @@ -package client - -import ( - "fmt" - "os" - - "charm.land/lipgloss/v2" -) - -func PrintWarning(msg string) { - style := lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // Bright yellow. - styledMsg := style.Render(fmt.Sprintf("WARNING: %s", msg)) - fmt.Fprintln(os.Stderr, styledMsg) -} diff --git a/pkg/client/volume.go b/pkg/client/volume.go index 6bf68913..6f9a756c 100644 --- a/pkg/client/volume.go +++ b/pkg/client/volume.go @@ -7,6 +7,7 @@ import ( "github.com/containerd/errdefs" "github.com/docker/compose/v2/pkg/progress" "github.com/docker/docker/api/types/volume" + "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/pkg/api" ) @@ -70,7 +71,7 @@ func (cli *Client) ListVolumes(ctx context.Context, filter *api.VolumeFilter) ([ for _, mv := range machineVolumes { if mv.Metadata != nil && mv.Metadata.Error != "" { // TODO: return failed machines in the response. - PrintWarning(fmt.Sprintf("failed to list volumes on machine '%s': %s", + tui.PrintWarning(fmt.Sprintf("failed to list volumes on machine '%s': %s", mv.Metadata.Machine, mv.Metadata.Error)) continue }