feat: add support for SSH RemoteCommand and RequestTTY in host configuration and TUI forms

- Allow users to specify a RemoteCommand to execute on SSH connection, both via TUI and config file
- Add RequestTTY option (yes, no, force, auto) to host configuration and forms
- Update config parsing and writing to handle new fields
- Improve TUI forms to support editing and adding these options
- Fix edit form standalone mode to allow proper quit/save via keyboard shortcuts
This commit is contained in:
Gu1llaum-3 2025-10-12 20:25:20 +02:00
parent 12d97270f0
commit c1457af73a
4 changed files with 171 additions and 58 deletions

View File

@ -140,11 +140,17 @@ func connectToHost(hostName string) {
fmt.Printf("Connecting to %s...\n", hostName) fmt.Printf("Connecting to %s...\n", hostName)
var sshCmd *exec.Cmd var sshCmd *exec.Cmd
var args []string
if configFile != "" { if configFile != "" {
sshCmd = exec.Command("ssh", "-F", configFile, hostName) args = append(args, "-F", configFile)
} else {
sshCmd = exec.Command("ssh", hostName)
} }
args = append(args, hostName)
// Note: We don't add RemoteCommand here because if it's configured in SSH config,
// SSH will handle it automatically. Adding it as a command line argument would conflict.
sshCmd = exec.Command("ssh", args...)
// Set up the command to use the same stdin, stdout, and stderr as the parent process // Set up the command to use the same stdin, stdout, and stderr as the parent process
sshCmd.Stdin = os.Stdin sshCmd.Stdin = os.Stdin

View File

@ -13,15 +13,17 @@ import (
// SSHHost represents an SSH host configuration // SSHHost represents an SSH host configuration
type SSHHost struct { type SSHHost struct {
Name string Name string
Hostname string Hostname string
User string User string
Port string Port string
Identity string Identity string
ProxyJump string ProxyJump string
Options string Options string
Tags []string RemoteCommand string // Command to execute after SSH connection
SourceFile string // Path to the config file where this host is defined RequestTTY string // Request TTY (yes, no, force, auto)
Tags []string
SourceFile string // Path to the config file where this host is defined
// Temporary field to handle multiple aliases during parsing // Temporary field to handle multiple aliases during parsing
aliasNames []string `json:"-"` // Do not serialize this field aliasNames []string `json:"-"` // Do not serialize this field
@ -326,6 +328,14 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
if currentHost != nil { if currentHost != nil {
currentHost.ProxyJump = value currentHost.ProxyJump = value
} }
case "remotecommand":
if currentHost != nil {
currentHost.RemoteCommand = value
}
case "requesttty":
if currentHost != nil {
currentHost.RequestTTY = value
}
default: default:
// Handle other SSH options // Handle other SSH options
if currentHost != nil && strings.TrimSpace(line) != "" { if currentHost != nil && strings.TrimSpace(line) != "" {
@ -603,6 +613,20 @@ func AddSSHHostToFile(host SSHHost, configPath string) error {
} }
} }
if host.RemoteCommand != "" {
_, err = file.WriteString(fmt.Sprintf(" RemoteCommand %s\n", host.RemoteCommand))
if err != nil {
return err
}
}
if host.RequestTTY != "" {
_, err = file.WriteString(fmt.Sprintf(" RequestTTY %s\n", host.RequestTTY))
if err != nil {
return err
}
}
// Write SSH options // Write SSH options
if host.Options != "" { if host.Options != "" {
// Split options by newlines and write each one // Split options by newlines and write each one
@ -1020,6 +1044,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
} }
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options // Write SSH options
if newHost.Options != "" { if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n") options := strings.Split(newHost.Options, "\n")
@ -1068,6 +1098,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
} }
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options // Write SSH options
if newHost.Options != "" { if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n") options := strings.Split(newHost.Options, "\n")
@ -1152,6 +1188,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
} }
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options // Write SSH options
if newHost.Options != "" { if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n") options := strings.Split(newHost.Options, "\n")
@ -1200,6 +1242,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
} }
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options // Write SSH options
if newHost.Options != "" { if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n") options := strings.Split(newHost.Options, "\n")
@ -1694,6 +1742,12 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH
if commonProperties.ProxyJump != "" { if commonProperties.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump) newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump)
} }
if commonProperties.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand)
}
if commonProperties.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+commonProperties.RequestTTY)
}
// Write SSH options // Write SSH options
if commonProperties.Options != "" { if commonProperties.Options != "" {
@ -1774,6 +1828,12 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH
if commonProperties.ProxyJump != "" { if commonProperties.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump) newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump)
} }
if commonProperties.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand)
}
if commonProperties.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+commonProperties.RequestTTY)
}
// Write SSH options // Write SSH options
if commonProperties.Options != "" { if commonProperties.Options != "" {

View File

@ -47,7 +47,7 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
} }
} }
inputs := make([]textinput.Model, 8) inputs := make([]textinput.Model, 10) // Increased from 9 to 10 for RequestTTY
// Name input // Name input
inputs[nameInput] = textinput.New() inputs[nameInput] = textinput.New()
@ -101,6 +101,18 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
inputs[tagsInput].CharLimit = 200 inputs[tagsInput].CharLimit = 200
inputs[tagsInput].Width = 50 inputs[tagsInput].Width = 50
// Remote Command input
inputs[remoteCommandInput] = textinput.New()
inputs[remoteCommandInput].Placeholder = "ls -la, htop, bash"
inputs[remoteCommandInput].CharLimit = 300
inputs[remoteCommandInput].Width = 70
// RequestTTY input
inputs[requestTTYInput] = textinput.New()
inputs[requestTTYInput].Placeholder = "yes, no, force, auto"
inputs[requestTTYInput].CharLimit = 10
inputs[requestTTYInput].Width = 30
return &addFormModel{ return &addFormModel{
inputs: inputs, inputs: inputs,
focused: nameInput, focused: nameInput,
@ -120,6 +132,8 @@ const (
proxyJumpInput proxyJumpInput
optionsInput optionsInput
tagsInput tagsInput
remoteCommandInput
requestTTYInput
) )
// Messages for communication with parent model // Messages for communication with parent model
@ -225,6 +239,8 @@ func (m *addFormModel) View() string {
"ProxyJump", "ProxyJump",
"SSH Options", "SSH Options",
"Tags (comma-separated)", "Tags (comma-separated)",
"Remote Command",
"Request TTY",
} }
for i, field := range fields { for i, field := range fields {
@ -291,6 +307,8 @@ func (m *addFormModel) submitForm() tea.Cmd {
identity := strings.TrimSpace(m.inputs[identityInput].Value()) identity := strings.TrimSpace(m.inputs[identityInput].Value())
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value()) proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
options := strings.TrimSpace(m.inputs[optionsInput].Value()) options := strings.TrimSpace(m.inputs[optionsInput].Value())
remoteCommand := strings.TrimSpace(m.inputs[remoteCommandInput].Value())
requestTTY := strings.TrimSpace(m.inputs[requestTTYInput].Value())
// Set defaults // Set defaults
if user == "" { if user == "" {
@ -319,14 +337,16 @@ func (m *addFormModel) submitForm() tea.Cmd {
// Create host configuration // Create host configuration
host := config.SSHHost{ host := config.SSHHost{
Name: name, Name: name,
Hostname: hostname, Hostname: hostname,
User: user, User: user,
Port: port, Port: port,
Identity: identity, Identity: identity,
ProxyJump: proxyJump, ProxyJump: proxyJump,
Options: config.ParseSSHOptionsFromCommand(options), Options: config.ParseSSHOptionsFromCommand(options),
Tags: tags, RemoteCommand: remoteCommand,
RequestTTY: requestTTY,
Tags: tags,
} }
// Add to config // Add to config

View File

@ -9,7 +9,6 @@ import (
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
const ( const (
@ -30,7 +29,6 @@ type editFormModel struct {
focusArea int // 0=hosts, 1=properties focusArea int // 0=hosts, 1=properties
focused int focused int
err string err string
success bool
styles Styles styles Styles
originalName string originalName string
originalHosts []string // Store original host names for multi-host detection originalHosts []string // Store original host names for multi-host detection
@ -92,7 +90,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
} }
} }
inputs := make([]textinput.Model, 7) // Reduced from 8 since we removed nameInput inputs := make([]textinput.Model, 9) // Increased from 8 to 9 for RequestTTY
// Hostname input // Hostname input
inputs[0] = textinput.New() inputs[0] = textinput.New()
@ -147,6 +145,20 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
inputs[6].SetValue(strings.Join(host.Tags, ", ")) inputs[6].SetValue(strings.Join(host.Tags, ", "))
} }
// Remote Command input
inputs[7] = textinput.New()
inputs[7].Placeholder = "ls -la, htop, bash"
inputs[7].CharLimit = 300
inputs[7].Width = 70
inputs[7].SetValue(host.RemoteCommand)
// RequestTTY input
inputs[8] = textinput.New()
inputs[8].Placeholder = "yes, no, force, auto"
inputs[8].CharLimit = 10
inputs[8].Width = 30
inputs[8].SetValue(host.RequestTTY)
return &editFormModel{ return &editFormModel{
hostInputs: hostInputs, hostInputs: hostInputs,
inputs: inputs, inputs: inputs,
@ -247,7 +259,6 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "ctrl+c", "esc": case "ctrl+c", "esc":
m.err = "" m.err = ""
m.success = false
return m, func() tea.Msg { return editFormCancelMsg{} } return m, func() tea.Msg { return editFormCancelMsg{} }
case "ctrl+s": case "ctrl+s":
@ -306,10 +317,10 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case editFormSubmitMsg: case editFormSubmitMsg:
if msg.err != nil { if msg.err != nil {
m.err = msg.err.Error() m.err = msg.err.Error()
m.success = false
} else { } else {
m.success = true // Success: let the wrapper handle this
m.err = "" // In TUI mode, this will be handled by the parent
// In standalone mode, the wrapper will quit
} }
return m, nil return m, nil
} }
@ -334,13 +345,6 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *editFormModel) View() string { func (m *editFormModel) View() string {
var b strings.Builder var b strings.Builder
if m.success {
b.WriteString(m.styles.FormField.Foreground(lipgloss.Color("#10B981")).Render("✓ Host updated successfully!"))
b.WriteString("\n\n")
b.WriteString(m.styles.FormHelp.Render("Press Ctrl+C or Esc to go back"))
return b.String()
}
if m.err != "" { if m.err != "" {
b.WriteString(m.styles.Error.Render("Error: " + m.err)) b.WriteString(m.styles.Error.Render("Error: " + m.err))
b.WriteString("\n\n") b.WriteString("\n\n")
@ -385,6 +389,8 @@ func (m *editFormModel) View() string {
"Proxy Jump", "Proxy Jump",
"SSH Options", "SSH Options",
"Tags (comma-separated)", "Tags (comma-separated)",
"Remote Command",
"Request TTY",
} }
for i, field := range fields { for i, field := range fields {
@ -416,6 +422,30 @@ func (m *editFormModel) View() string {
return b.String() return b.String()
} }
// Standalone wrapper for edit form
type standaloneEditForm struct {
*editFormModel
}
func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case editFormSubmitMsg:
if msg.err != nil {
m.editFormModel.err = msg.err.Error()
return m, nil
} else {
// Success: quit the program
return m, tea.Quit
}
case editFormCancelMsg:
return m, tea.Quit
}
newForm, cmd := m.editFormModel.Update(msg)
m.editFormModel = newForm.(*editFormModel)
return m, cmd
}
// RunEditForm runs the edit form as a standalone program // RunEditForm runs the edit form as a standalone program
func RunEditForm(hostName string, configFile string) error { func RunEditForm(hostName string, configFile string) error {
styles := NewStyles(80) // Default width styles := NewStyles(80) // Default width
@ -424,17 +454,10 @@ func RunEditForm(hostName string, configFile string) error {
return err return err
} }
p := tea.NewProgram(editForm, tea.WithAltScreen()) m := standaloneEditForm{editForm}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err = p.Run() _, err = p.Run()
if err != nil { return err
return err
}
if editForm.err != "" {
return fmt.Errorf(editForm.err)
}
return nil
} }
func (m *editFormModel) submitEditForm() tea.Cmd { func (m *editFormModel) submitEditForm() tea.Cmd {
@ -453,12 +476,14 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
} }
// Get property values using direct indices // Get property values using direct indices
hostname := strings.TrimSpace(m.inputs[0].Value()) // hostnameInput hostname := strings.TrimSpace(m.inputs[0].Value()) // hostnameInput
user := strings.TrimSpace(m.inputs[1].Value()) // userInput user := strings.TrimSpace(m.inputs[1].Value()) // userInput
port := strings.TrimSpace(m.inputs[2].Value()) // portInput port := strings.TrimSpace(m.inputs[2].Value()) // portInput
identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput
proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput
options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput
remoteCommand := strings.TrimSpace(m.inputs[7].Value()) // remoteCommandInput
requestTTY := strings.TrimSpace(m.inputs[8].Value()) // requestTTYInput
// Set defaults // Set defaults
if port == "" { if port == "" {
@ -491,13 +516,15 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
// Create the common host configuration // Create the common host configuration
commonHost := config.SSHHost{ commonHost := config.SSHHost{
Hostname: hostname, Hostname: hostname,
User: user, User: user,
Port: port, Port: port,
Identity: identity, Identity: identity,
ProxyJump: proxyJump, ProxyJump: proxyJump,
Options: options, Options: options,
Tags: tags, RemoteCommand: remoteCommand,
RequestTTY: requestTTY,
Tags: tags,
} }
var err error var err error