mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
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:
36
README.md
36
README.md
@@ -229,6 +229,15 @@ sshm
|
||||
# Connect directly to a specific host (with history tracking)
|
||||
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
|
||||
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
|
||||
- **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
|
||||
|
||||
SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe.
|
||||
|
||||
61
cmd/root.go
61
cmd/root.go
@@ -24,36 +24,49 @@ var AppVersion = "dev"
|
||||
// configFile holds the path to the SSH config file
|
||||
var configFile string
|
||||
|
||||
// forceTTY forces pseudo-TTY allocation for remote commands
|
||||
var forceTTY bool
|
||||
|
||||
// searchMode enables the focus on search mode at startup
|
||||
var searchMode bool
|
||||
|
||||
// RootCmd is the base command when called without any subcommands
|
||||
var RootCmd = &cobra.Command{
|
||||
Use: "sshm [host]",
|
||||
Use: "sshm [host] [command...]",
|
||||
Short: "SSH Manager - A modern SSH connection manager",
|
||||
Long: `SSHM is a modern SSH manager for your terminal.
|
||||
|
||||
Main usage:
|
||||
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> <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.
|
||||
|
||||
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,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true, // We'll handle errors ourselves
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// If no arguments provided, run interactive mode
|
||||
if len(args) == 0 {
|
||||
runInteractiveMode()
|
||||
return nil
|
||||
}
|
||||
|
||||
// If a host name is provided, connect directly
|
||||
hostName := args[0]
|
||||
connectToHost(hostName)
|
||||
var remoteCommand []string
|
||||
if len(args) > 1 {
|
||||
remoteCommand = args[1:]
|
||||
}
|
||||
connectToHost(hostName, remoteCommand)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -105,8 +118,7 @@ func runInteractiveMode() {
|
||||
}
|
||||
}
|
||||
|
||||
func connectToHost(hostName string) {
|
||||
// Quick check if host exists without full parsing (optimized for connection)
|
||||
func connectToHost(hostName string, remoteCommand []string) {
|
||||
var hostFound bool
|
||||
var err error
|
||||
|
||||
@@ -126,45 +138,42 @@ func connectToHost(hostName string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Record the connection in history
|
||||
historyManager, err := history.NewHistoryManager()
|
||||
if err != nil {
|
||||
// Log the error but don't prevent the connection
|
||||
fmt.Printf("Warning: Could not initialize connection history: %v\n", err)
|
||||
} else {
|
||||
err = historyManager.RecordConnection(hostName)
|
||||
if err != nil {
|
||||
// Log the error but don't prevent the connection
|
||||
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
|
||||
|
||||
if configFile != "" {
|
||||
args = append(args, "-F", configFile)
|
||||
}
|
||||
|
||||
if forceTTY {
|
||||
args = append(args, "-t")
|
||||
}
|
||||
|
||||
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.
|
||||
if len(remoteCommand) > 0 {
|
||||
args = append(args, remoteCommand...)
|
||||
} else {
|
||||
fmt.Printf("Connecting to %s...\n", hostName)
|
||||
}
|
||||
|
||||
sshCmd = exec.Command("ssh", args...)
|
||||
|
||||
// Set up the command to use the same stdin, stdout, and stderr as the parent process
|
||||
sshCmd := exec.Command("ssh", args...)
|
||||
sshCmd.Stdin = os.Stdin
|
||||
sshCmd.Stdout = os.Stdout
|
||||
sshCmd.Stderr = os.Stderr
|
||||
|
||||
// Execute the SSH command
|
||||
err = sshCmd.Run()
|
||||
if err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
// SSH command failed, exit with the same code
|
||||
if status, ok := exitError.Sys().(syscall.WaitStatus); ok {
|
||||
os.Exit(status.ExitStatus())
|
||||
}
|
||||
@@ -200,17 +209,13 @@ func getVersionWithUpdateCheck() string {
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
func Execute() {
|
||||
// Custom error handling for unknown commands that might be host names
|
||||
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()
|
||||
if strings.Contains(errStr, "unknown command") {
|
||||
// Extract the command name from the error
|
||||
parts := strings.Split(errStr, "\"")
|
||||
if len(parts) >= 2 {
|
||||
potentialHost := parts[1]
|
||||
// Try to connect to this as a host
|
||||
connectToHost(potentialHost)
|
||||
connectToHost(potentialHost, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -220,8 +225,8 @@ func Execute() {
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Add the config file flag
|
||||
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")
|
||||
|
||||
// Set custom version template with update check
|
||||
|
||||
@@ -7,9 +7,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRootCommand(t *testing.T) {
|
||||
// Test that the root command is properly configured
|
||||
if RootCmd.Use != "sshm [host]" {
|
||||
t.Errorf("Expected Use 'sshm [host]', got '%s'", RootCmd.Use)
|
||||
if RootCmd.Use != "sshm [host] [command...]" {
|
||||
t.Errorf("Expected Use 'sshm [host] [command...]', got '%s'", RootCmd.Use)
|
||||
}
|
||||
|
||||
if RootCmd.Short != "SSH Manager - A modern SSH connection manager" {
|
||||
@@ -22,10 +21,8 @@ func TestRootCommand(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRootCommandFlags(t *testing.T) {
|
||||
// Test that persistent flags are properly configured
|
||||
flags := RootCmd.PersistentFlags()
|
||||
|
||||
// Check config flag
|
||||
configFlag := flags.Lookup("config")
|
||||
if configFlag == nil {
|
||||
t.Error("Expected --config flag to be defined")
|
||||
@@ -34,6 +31,15 @@ func TestRootCommandFlags(t *testing.T) {
|
||||
if configFlag.Shorthand != "c" {
|
||||
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) {
|
||||
@@ -103,13 +109,17 @@ func TestExecuteFunction(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")
|
||||
}
|
||||
|
||||
// The function will handle errors internally (like host not found)
|
||||
// We don't want to actually test the SSH connection in unit tests
|
||||
func TestRemoteCommandUsage(t *testing.T) {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user