Files
Anton Ovchinnikov e4455e936a fix(cli): stop leaking terminal capability replies into the shell (#435)
* chore(deps): bump bubbletea to v2.0.10, drop the uncloud fork

v2.0.10 carries the upstream fix for terminal capability replies leaking into
the shell (charmbracelet/bubbletea#1801). It skips every query that expects a
reply when input is disabled: the startup probe for synchronized output and
Unicode core, clipboard reads, background, foreground and cursor colour
requests, cursor position reports, and terminal version and termcap queries.

That supersedes the unlabs-dev fork, whose patch covered only the startup
probe, so the replace directive goes away and uncloud tracks upstream again.

The bump on its own does not fix the leak. Bubble Tea skips the queries only
when a program declares that it has no input, and nothing in uncloud declares
that yet. Measured: this tree still leaks "^[[?2026;2$y^[[?2027;0$y" under a
PTY harness. The next commit is what makes the spinners declare it.

Refs: #286, charmbracelet/bubbletea#1801
2026-09-28 10:16:45 +10:00

220 lines
5.9 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"os"
"time"
"charm.land/bubbles/v2/spinner"
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"
)
// ConnectOptions configures the behavior of cluster connection attempts.
type ConnectOptions struct {
// Whether to show connection progress spinner if stdout is a terminal or progress logs if not.
ShowProgress bool
}
func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts ConnectOptions) (*client.Client, error) {
if opts.ShowProgress {
return connectClusterWithProgress(ctx, conn)
}
return connectCluster(ctx, conn)
}
// connectClusterWithProgress connects to the cluster while displaying a progress spinner.
// 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 !tui.IsTerminalAvailable() {
fmt.Fprintln(os.Stderr, "Connecting to", conn.String())
cli, err := connectCluster(ctx, conn)
if err != nil {
fmt.Fprintln(os.Stderr, "Connection failed:", err)
} else {
fmt.Fprintln(os.Stderr, "Connected to cluster.")
}
return cli, err
}
// 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),
tea.WithInput(nil),
// Ctrl+C handling
tea.WithFilter(func(_ tea.Model, msg tea.Msg) tea.Msg {
if _, ok := msg.(tea.InterruptMsg); ok {
return connectResultMsg{err: errors.New("connection cancelled")}
}
return msg
}),
)
model, err := p.Run()
if err != nil {
return nil, fmt.Errorf("run connection TUI: %w", err)
}
m := model.(connectModel)
return m.result.client, m.result.err
}
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
// Determine which SSH type is configured.
var sshDest config.SSHDestination
var useGoSSH bool
// Validate connection configuration early to provide clear error messages.
if err := conn.Validate(); err != nil {
return nil, fmt.Errorf("invalid connection configuration: %w", err)
}
if conn.SSH != "" {
// SSH uses the system ssh CLI command (default).
sshDest = conn.SSH
} else if conn.SSHCLI != "" {
// SSHCLI is a backward-compatible alias for SSH.
sshDest = conn.SSHCLI
} else if conn.SSHGo != "" {
// SSHGo uses Go's built-in SSH library.
sshDest = conn.SSHGo
useGoSSH = true
} else if conn.TCP != nil && conn.TCP.IsValid() {
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
} else if conn.Unix != "" {
return client.New(ctx, connector.NewUnixConnector(conn.Unix))
} else {
return nil, errors.New("connection configuration is invalid")
}
// Parse SSH destination and create config (shared for both types)
user, host, port, err := sshDest.Parse()
if err != nil {
return nil, fmt.Errorf("parse SSH connection %q: %w", sshDest, err)
}
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
sshConfig := &connector.SSHConnectorConfig{
User: user,
Host: host,
Port: port,
KeyPath: keyPath,
}
// Create appropriate connector based on type.
if useGoSSH {
return client.New(ctx, connector.NewSSHConnector(sshConfig))
}
return client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
}
// connectModel is a TUI model for connecting to a cluster with a progress spinner.
type connectModel struct {
ctx context.Context
conn config.MachineConnection
spinner spinner.Model
// showSpinner controls whether the spinner is visible (delayed to avoid flashing).
showSpinner bool
// done indicates whether the connection attempt has completed (successfully or with error).
done bool
// result holds the result of the connection attempt.
result connectResultMsg
}
type connectResultMsg struct {
client *client.Client
err error
}
// showSpinnerMsg is sent after a delay to show the spinner.
type showSpinnerMsg struct{}
func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel {
s := spinner.New()
s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Yellow) // the same yellow as in compose progress
return connectModel{
ctx: ctx,
conn: conn,
spinner: s,
}
}
func (m connectModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
m.connect(),
m.delayShowSpinner(),
)
}
func (m connectModel) connect() tea.Cmd {
return func() tea.Msg {
cli, err := connectCluster(m.ctx, m.conn)
return connectResultMsg{
client: cli,
err: err,
}
}
}
// delayShowSpinner returns a command that sends a message to show the spinner after a delay.
// This avoids flashing the spinner if the connection is fast.
func (m connectModel) delayShowSpinner() tea.Cmd {
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
return showSpinnerMsg{}
})
}
func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
select {
case <-m.ctx.Done():
m.result.err = m.ctx.Err()
m.done = true
return m, tea.Quit
default:
}
switch msg := msg.(type) {
case connectResultMsg:
m.result = msg
m.done = true
return m, tea.Quit
case showSpinnerMsg:
// Only show spinner if connection hasn't completed yet.
if !m.done {
m.showSpinner = true
}
return m, nil
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
}
func (m connectModel) View() tea.View {
// Don't show anything if done or spinner not yet visible.
if m.done || !m.showSpinner {
return tea.NewView("")
}
style := lipgloss.NewStyle().Foreground(lipgloss.Color("153"))
return tea.NewView(fmt.Sprintf("%s %s\n",
m.spinner.View(),
fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())),
))
}