diff --git a/README.md b/README.md index c54c51a..c9ad6e6 100644 --- a/README.md +++ b/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. diff --git a/cmd/root.go b/cmd/root.go index 710a44c..cf9926c 100644 --- a/cmd/root.go +++ b/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 ' connects directly to the specified host and records the connection in your history. + Running 'sshm ' 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 diff --git a/cmd/root_test.go b/cmd/root_test.go index 9571305..e56c39b 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -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) {