mirror of
https://github.com/psviderski/uncloud.git
synced 2026-10-06 21:29:02 +00:00
* 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
220 lines
5.9 KiB
Go
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())),
|
|
))
|
|
}
|