feat: add remote command execution support (#36)

Allow executing commands on remote hosts via 'sshm <host> <command>'.
Add -t/--tty flag for forcing TTY allocation on interactive commands.

Co-authored-by: Guillaume Archambault <67098259+Gu1llaum-3@users.noreply.github.com>
This commit is contained in:
David Ibia
2026-01-04 19:24:31 +01:00
committed by GitHub
parent 66cb80f29c
commit 435597f694
3 changed files with 89 additions and 38 deletions

View File

@@ -229,6 +229,15 @@ sshm
# Connect directly to a specific host (with history tracking) # Connect directly to a specific host (with history tracking)
sshm my-server sshm my-server
# Execute a command on a remote host
sshm my-server uptime
# Execute command with arguments
sshm my-server ls -la /var/log
# Force TTY allocation for interactive commands
sshm -t my-server sudo systemctl restart nginx
# Launch TUI with custom SSH config file # Launch TUI with custom SSH config file
sshm -c /path/to/custom/ssh_config sshm -c /path/to/custom/ssh_config
@@ -286,6 +295,33 @@ sshm web-01
- **Error handling** - Clear messages if host doesn't exist or configuration issues - **Error handling** - Clear messages if host doesn't exist or configuration issues
- **Config file support** - Works with custom config files using `-c` flag - **Config file support** - Works with custom config files using `-c` flag
### Remote Command Execution
Execute commands on remote hosts without opening an interactive shell:
```bash
# Execute a single command
sshm prod-server uptime
# Execute command with arguments
sshm prod-server ls -la /var/log
# Check disk usage
sshm prod-server df -h
# View logs (pipe to local commands)
sshm prod-server 'cat /var/log/nginx/access.log' | grep 404
# Force TTY allocation for interactive commands (sudo, vim, etc.)
sshm -t prod-server sudo systemctl restart nginx
```
**Features:**
- **Exit code propagation** - Remote command exit codes are passed through
- **TTY support** - Use `-t` flag for commands requiring terminal interaction
- **Pipe-friendly** - Output can be piped to local commands for processing
- **History tracking** - Command executions are recorded in connection history
### Backup Configuration ### Backup Configuration
SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe. SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe.

View File

@@ -24,36 +24,49 @@ var AppVersion = "dev"
// configFile holds the path to the SSH config file // configFile holds the path to the SSH config file
var configFile string var configFile string
// forceTTY forces pseudo-TTY allocation for remote commands
var forceTTY bool
// searchMode enables the focus on search mode at startup // searchMode enables the focus on search mode at startup
var searchMode bool var searchMode bool
// RootCmd is the base command when called without any subcommands // RootCmd is the base command when called without any subcommands
var RootCmd = &cobra.Command{ var RootCmd = &cobra.Command{
Use: "sshm [host]", Use: "sshm [host] [command...]",
Short: "SSH Manager - A modern SSH connection manager", Short: "SSH Manager - A modern SSH connection manager",
Long: `SSHM is a modern SSH manager for your terminal. Long: `SSHM is a modern SSH manager for your terminal.
Main usage: Main usage:
Running 'sshm' (without arguments) opens the interactive TUI window to browse, search, and connect to your SSH hosts graphically. Running 'sshm' (without arguments) opens the interactive TUI window to browse, search, and connect to your SSH hosts graphically.
Running 'sshm <host>' connects directly to the specified host and records the connection in your history. Running 'sshm <host>' connects directly to the specified host and records the connection in your history.
Running 'sshm <host> <command>' executes the command on the remote host and returns the output.
You can also use sshm in CLI mode for other operations like adding, editing, or searching hosts. You can also use sshm in CLI mode for other operations like adding, editing, or searching hosts.
Hosts are read from your ~/.ssh/config file by default.`, Hosts are read from your ~/.ssh/config file by default.
Examples:
sshm # Open interactive TUI
sshm prod-server # Connect to host interactively
sshm prod-server uptime # Execute 'uptime' on remote host
sshm prod-server ls -la /var # Execute command with arguments
sshm -t prod-server sudo reboot # Force TTY for interactive commands`,
Version: AppVersion, Version: AppVersion,
Args: cobra.ArbitraryArgs, Args: cobra.ArbitraryArgs,
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, // We'll handle errors ourselves SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
// If no arguments provided, run interactive mode
if len(args) == 0 { if len(args) == 0 {
runInteractiveMode() runInteractiveMode()
return nil return nil
} }
// If a host name is provided, connect directly
hostName := args[0] hostName := args[0]
connectToHost(hostName) var remoteCommand []string
if len(args) > 1 {
remoteCommand = args[1:]
}
connectToHost(hostName, remoteCommand)
return nil return nil
}, },
} }
@@ -105,8 +118,7 @@ func runInteractiveMode() {
} }
} }
func connectToHost(hostName string) { func connectToHost(hostName string, remoteCommand []string) {
// Quick check if host exists without full parsing (optimized for connection)
var hostFound bool var hostFound bool
var err error var err error
@@ -126,45 +138,42 @@ func connectToHost(hostName string) {
os.Exit(1) os.Exit(1)
} }
// Record the connection in history
historyManager, err := history.NewHistoryManager() historyManager, err := history.NewHistoryManager()
if err != nil { if err != nil {
// Log the error but don't prevent the connection
fmt.Printf("Warning: Could not initialize connection history: %v\n", err) fmt.Printf("Warning: Could not initialize connection history: %v\n", err)
} else { } else {
err = historyManager.RecordConnection(hostName) err = historyManager.RecordConnection(hostName)
if err != nil { if err != nil {
// Log the error but don't prevent the connection
fmt.Printf("Warning: Could not record connection history: %v\n", err) fmt.Printf("Warning: Could not record connection history: %v\n", err)
} }
} }
// Build and execute the SSH command
fmt.Printf("Connecting to %s...\n", hostName)
var sshCmd *exec.Cmd
var args []string var args []string
if configFile != "" { if configFile != "" {
args = append(args, "-F", configFile) args = append(args, "-F", configFile)
} }
if forceTTY {
args = append(args, "-t")
}
args = append(args, hostName) args = append(args, hostName)
// Note: We don't add RemoteCommand here because if it's configured in SSH config, if len(remoteCommand) > 0 {
// SSH will handle it automatically. Adding it as a command line argument would conflict. args = append(args, remoteCommand...)
} else {
fmt.Printf("Connecting to %s...\n", hostName)
}
sshCmd = exec.Command("ssh", args...) sshCmd := exec.Command("ssh", args...)
// Set up the command to use the same stdin, stdout, and stderr as the parent process
sshCmd.Stdin = os.Stdin sshCmd.Stdin = os.Stdin
sshCmd.Stdout = os.Stdout sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr sshCmd.Stderr = os.Stderr
// Execute the SSH command
err = sshCmd.Run() err = sshCmd.Run()
if err != nil { if err != nil {
if exitError, ok := err.(*exec.ExitError); ok { if exitError, ok := err.(*exec.ExitError); ok {
// SSH command failed, exit with the same code
if status, ok := exitError.Sys().(syscall.WaitStatus); ok { if status, ok := exitError.Sys().(syscall.WaitStatus); ok {
os.Exit(status.ExitStatus()) os.Exit(status.ExitStatus())
} }
@@ -200,17 +209,13 @@ func getVersionWithUpdateCheck() string {
// Execute adds all child commands to the root command and sets flags appropriately. // Execute adds all child commands to the root command and sets flags appropriately.
func Execute() { func Execute() {
// Custom error handling for unknown commands that might be host names
if err := RootCmd.Execute(); err != nil { if err := RootCmd.Execute(); err != nil {
// Check if this is an "unknown command" error and the argument might be a host name
errStr := err.Error() errStr := err.Error()
if strings.Contains(errStr, "unknown command") { if strings.Contains(errStr, "unknown command") {
// Extract the command name from the error
parts := strings.Split(errStr, "\"") parts := strings.Split(errStr, "\"")
if len(parts) >= 2 { if len(parts) >= 2 {
potentialHost := parts[1] potentialHost := parts[1]
// Try to connect to this as a host connectToHost(potentialHost, nil)
connectToHost(potentialHost)
return return
} }
} }
@@ -220,8 +225,8 @@ func Execute() {
} }
func init() { func init() {
// Add the config file flag
RootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)") RootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)")
RootCmd.Flags().BoolVarP(&forceTTY, "tty", "t", false, "Force pseudo-TTY allocation (useful for interactive remote commands)")
RootCmd.PersistentFlags().BoolVarP(&searchMode, "search", "s", false, "Focus on search input at startup") RootCmd.PersistentFlags().BoolVarP(&searchMode, "search", "s", false, "Focus on search input at startup")
// Set custom version template with update check // Set custom version template with update check

View File

@@ -7,9 +7,8 @@ import (
) )
func TestRootCommand(t *testing.T) { func TestRootCommand(t *testing.T) {
// Test that the root command is properly configured if RootCmd.Use != "sshm [host] [command...]" {
if RootCmd.Use != "sshm [host]" { t.Errorf("Expected Use 'sshm [host] [command...]', got '%s'", RootCmd.Use)
t.Errorf("Expected Use 'sshm [host]', got '%s'", RootCmd.Use)
} }
if RootCmd.Short != "SSH Manager - A modern SSH connection manager" { if RootCmd.Short != "SSH Manager - A modern SSH connection manager" {
@@ -22,10 +21,8 @@ func TestRootCommand(t *testing.T) {
} }
func TestRootCommandFlags(t *testing.T) { func TestRootCommandFlags(t *testing.T) {
// Test that persistent flags are properly configured
flags := RootCmd.PersistentFlags() flags := RootCmd.PersistentFlags()
// Check config flag
configFlag := flags.Lookup("config") configFlag := flags.Lookup("config")
if configFlag == nil { if configFlag == nil {
t.Error("Expected --config flag to be defined") t.Error("Expected --config flag to be defined")
@@ -34,6 +31,15 @@ func TestRootCommandFlags(t *testing.T) {
if configFlag.Shorthand != "c" { if configFlag.Shorthand != "c" {
t.Errorf("Expected config flag shorthand 'c', got '%s'", configFlag.Shorthand) t.Errorf("Expected config flag shorthand 'c', got '%s'", configFlag.Shorthand)
} }
ttyFlag := RootCmd.Flags().Lookup("tty")
if ttyFlag == nil {
t.Error("Expected --tty flag to be defined")
return
}
if ttyFlag.Shorthand != "t" {
t.Errorf("Expected tty flag shorthand 't', got '%s'", ttyFlag.Shorthand)
}
} }
func TestRootCommandSubcommands(t *testing.T) { func TestRootCommandSubcommands(t *testing.T) {
@@ -103,13 +109,17 @@ func TestExecuteFunction(t *testing.T) {
} }
func TestConnectToHostFunction(t *testing.T) { func TestConnectToHostFunction(t *testing.T) {
// Test that connectToHost function exists and can be called
// Note: We can't easily test the actual connection without a valid SSH config
// and without actually connecting to a host, but we can verify the function exists
t.Log("connectToHost function exists and is accessible") t.Log("connectToHost function exists and is accessible")
}
// The function will handle errors internally (like host not found) func TestRemoteCommandUsage(t *testing.T) {
// We don't want to actually test the SSH connection in unit tests if !strings.Contains(RootCmd.Long, "command") {
t.Error("Long description should mention remote command execution")
}
if !strings.Contains(RootCmd.Long, "uptime") {
t.Error("Long description should include command examples")
}
} }
func TestRunInteractiveModeFunction(t *testing.T) { func TestRunInteractiveModeFunction(t *testing.T) {