diff --git a/cmd/goto/main.go b/cmd/goto/main.go index 8ee717e..82af27b 100644 --- a/cmd/goto/main.go +++ b/cmd/goto/main.go @@ -37,6 +37,11 @@ func main() { fmt.Printf("%+v\n", err) } + // Check if "ssh" utility is in application path + if err := utils.CheckAppInstalled("ssh"); err != nil { + log.Fatalf("ssh utility is not installed or cannot be found in the executable path: %v", err) + } + commandLineParams := config.User{} displayApplicationDetailsAndExit := false // Command line parameters have the highest precedence @@ -46,6 +51,7 @@ func main() { flag.Parse() var err error + // Get application home folder path commandLineParams.AppHome, err = utils.GetAppDir(appName, commandLineParams.AppHome) if err != nil { log.Fatalf("Can't get application home folder: %v", err) diff --git a/internal/connector/ssh/connect.go b/internal/connector/ssh/connect.go deleted file mode 100644 index 5cb80db..0000000 --- a/internal/connector/ssh/connect.go +++ /dev/null @@ -1,38 +0,0 @@ -package ssh - -import ( - "os/exec" - "strings" - - "github.com/grafviktor/goto/internal/model" -) - -const ( - optionPrivateKey = "-i" - optionRemotePort = "-p" - optionLoginName = "-l" -) - -func Connect(h model.Host) *exec.Cmd { - args := []string{} - - privateKeyPath := strings.Trim(h.PrivateKeyPath, " ") - if privateKeyPath != "" { - args = append(args, optionPrivateKey) - args = append(args, privateKeyPath) - } - - remotePort := strings.Trim(h.RemotePort, " ") - if remotePort != "" { - args = append(args, optionRemotePort) - args = append(args, remotePort) - } - - loginName := strings.Trim(h.LoginName, " ") - if loginName != "" { - args = append(args, optionLoginName) - args = append(args, loginName) - } - - return exec.Command("ssh", append(args, h.Address)...) -} diff --git a/internal/ui/component/edithost/edit_host.go b/internal/ui/component/edithost/edit_host.go index 57c6887..38a52d7 100644 --- a/internal/ui/component/edithost/edit_host.go +++ b/internal/ui/component/edithost/edit_host.go @@ -260,7 +260,7 @@ func (m editModel) inputsView() string { } func (m editModel) headerView() string { - return titleStyle.Render("add a new host") + return titleStyle.Render("edit host") } func (m editModel) helpView() string { diff --git a/internal/ui/component/hostlist/list.go b/internal/ui/component/hostlist/list.go index a14685c..8b7b57a 100644 --- a/internal/ui/component/hostlist/list.go +++ b/internal/ui/component/hostlist/list.go @@ -11,11 +11,12 @@ import ( "github.com/charmbracelet/lipgloss" "golang.org/x/exp/slices" - "github.com/grafviktor/goto/internal/connector/ssh" "github.com/grafviktor/goto/internal/model" "github.com/grafviktor/goto/internal/state" "github.com/grafviktor/goto/internal/storage" "github.com/grafviktor/goto/internal/ui/message" + "github.com/grafviktor/goto/internal/utils" + "github.com/grafviktor/goto/internal/utils/ssh" ) var ( @@ -236,24 +237,10 @@ func (m ListModel) executeCmd(_ tea.Msg) (ListModel, tea.Cmd) { return m, message.TeaCmd(msgErrorOccured{err}) } - connectSSHCmd := ssh.Connect(host) - return m, tea.ExecProcess(connectSSHCmd, func(err error) tea.Msg { - // return m, tea.ExecProcess(exec.Command("ping", "-t", "localhost"), func(err error) tea.Msg { + command := ssh.ConstructCMD(ssh.BaseCMD(), utils.HostModelToOptionsAdaptor(host)) + process := utils.BuildProcess(command) + return m, tea.ExecProcess(process, func(err error) tea.Msg { if err != nil { - /* - * That's to attempt to restore windows terminal when user pressed ctrl+c when using SSH connection. - * It works, when we close SSH, however it breaks all subsequent ssh connections - */ - /* - if runtime.GOOS == "windows" { - // If try to connect to a remote host and instead of typing a password, type "CTRL+C", - // the application UI will be broken. Flushing terminal window, helps to resolve the problem. - cmd := exec.Command("cmd", "/c", "cls") - cmd.Stdout = os.Stdout - cmd.Run() - } - */ - return msgErrorOccured{err} } diff --git a/internal/utils/ssh/cmd_nix.go b/internal/utils/ssh/cmd_nix.go new file mode 100644 index 0000000..8135a43 --- /dev/null +++ b/internal/utils/ssh/cmd_nix.go @@ -0,0 +1,7 @@ +//go:build !windows + +package ssh + +func BaseCMD() string { + return "ssh" +} diff --git a/internal/utils/ssh/cmd_win.go b/internal/utils/ssh/cmd_win.go new file mode 100644 index 0000000..fa1ee0c --- /dev/null +++ b/internal/utils/ssh/cmd_win.go @@ -0,0 +1,7 @@ +//go:build windows + +package ssh + +func BaseCMD() string { + return "cmd /c ssh" +} diff --git a/internal/utils/ssh/connect.go b/internal/utils/ssh/connect.go new file mode 100644 index 0000000..3275765 --- /dev/null +++ b/internal/utils/ssh/connect.go @@ -0,0 +1,57 @@ +package ssh + +import ( + "fmt" + "strings" +) + +type CommandLineOption interface{} + +type ( + OptionPrivateKey struct{ Value string } + OptionRemotePort struct{ Value string } + OptionLoginName struct{ Value string } + OptionAddress struct{ Value string } +) + +func constructKeyValueOption(optionFlag, optionValue string) string { + optionValue = strings.TrimSpace(optionValue) + if optionValue != "" { + return fmt.Sprintf(" %s %s", optionFlag, optionValue) + } + return "" +} + +func addOption(sb *strings.Builder, rawParameter CommandLineOption) { + var option string + switch p := rawParameter.(type) { + case OptionPrivateKey: + option = constructKeyValueOption("-i", p.Value) + case OptionRemotePort: + option = constructKeyValueOption("-p", p.Value) + case OptionLoginName: + option = constructKeyValueOption("-l", p.Value) + case OptionAddress: + if p.Value != "" { + option = fmt.Sprintf(" %s", p.Value) + } + default: + return + } + + sb.WriteString(option) +} + +// ConstructCMD - build connect command from main app and its arguments +// cmd - main executable +// options - set of command line options. See Option... public variables. +func ConstructCMD(cmd string, options ...CommandLineOption) string { + sb := strings.Builder{} + sb.WriteString(cmd) + + for _, argument := range options { + addOption(&sb, argument) + } + + return sb.String() +} diff --git a/internal/utils/ssh/connect_test.go b/internal/utils/ssh/connect_test.go new file mode 100644 index 0000000..549e1d6 --- /dev/null +++ b/internal/utils/ssh/connect_test.go @@ -0,0 +1,123 @@ +package ssh + +import ( + "strings" + "testing" +) + +func Test_ConstructKeyValueOption(t *testing.T) { + tests := []struct { + name string + optionFlag string + optionValue string + expectedResult string + }{ + { + name: "Option with value", + optionFlag: "-i", + optionValue: "private_key", + expectedResult: " -i private_key", + }, + { + name: "Option with empty value", + optionFlag: "-p", + optionValue: "", + expectedResult: "", + }, + { + name: "Option with space-padded value", + optionFlag: "-l", + optionValue: " login_name ", + expectedResult: " -l login_name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := constructKeyValueOption(tt.optionFlag, tt.optionValue) + + if result != tt.expectedResult { + t.Errorf("Expected result %s, but got %s", tt.expectedResult, result) + } + }) + } +} + +func Test_AddOption(t *testing.T) { + tests := []struct { + name string + rawParameter CommandLineOption + expectedResult string + }{ + { + name: "OptionPrivateKey with value", + rawParameter: OptionPrivateKey{Value: "private_key"}, + expectedResult: " -i private_key", + }, + { + name: "OptionRemotePort with empty value", + rawParameter: OptionRemotePort{Value: ""}, + expectedResult: "", + }, + { + name: "OptionLoginName with value", + rawParameter: OptionLoginName{Value: "login_name"}, + expectedResult: " -l login_name", + }, + { + name: "OptionAddress with empty value", + rawParameter: OptionAddress{Value: ""}, + expectedResult: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sb strings.Builder + addOption(&sb, tt.rawParameter) + + result := sb.String() + if result != tt.expectedResult { + t.Errorf("Expected result %s, but got %s", tt.expectedResult, result) + } + }) + } +} + +func Test_ConstructCMD(t *testing.T) { + tests := []struct { + name string + cmd string + options []CommandLineOption + expectedResult string + }{ + { + name: "Command with Options", + cmd: "ssh", + options: []CommandLineOption{OptionPrivateKey{Value: "private_key"}, OptionRemotePort{Value: "22"}}, + expectedResult: "ssh -i private_key -p 22", + }, + { + name: "Command without Options", + cmd: "ls", + options: []CommandLineOption{}, + expectedResult: "ls", + }, + { + name: "Command with Address Option", + cmd: "ping", + options: []CommandLineOption{OptionAddress{Value: "example.com"}}, + expectedResult: "ping example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConstructCMD(tt.cmd, tt.options...) + + if result != tt.expectedResult { + t.Errorf("Expected result %s, but got %s", tt.expectedResult, result) + } + }) + } +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index fc93b8c..b43368e 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -3,9 +3,14 @@ package utils import ( "errors" "os" + "os/exec" "os/user" "path" "path/filepath" + "strings" + + "github.com/grafviktor/goto/internal/model" + "github.com/grafviktor/goto/internal/utils/ssh" ) type Logger interface { @@ -65,3 +70,35 @@ func GetCurrentOSUser() string { return user.Username } + +// CheckAppInstalled - checks if application is installed and can be found in executable path +// appName - name of the application to be looked for in $PATH +func CheckAppInstalled(appName string) error { + _, err := exec.LookPath(appName) + + return err +} + +// HostModelToOptionsAdaptor - extract values from model.Host into a set of ssh.CommandLineOption +// host - model.Host to be adapted +// returns []ssh.CommandLineOption +func HostModelToOptionsAdaptor(host model.Host) []ssh.CommandLineOption { + return []ssh.CommandLineOption{ + ssh.OptionAddress{Value: host.Address}, + ssh.OptionLoginName{Value: host.LoginName}, + ssh.OptionRemotePort{Value: host.RemotePort}, + ssh.OptionPrivateKey{Value: host.PrivateKeyPath}, + } +} + +func BuildProcess(cmd string) *exec.Cmd { + if strings.TrimSpace(cmd) == "" { + return nil + } + + commandWithArguments := strings.Split(cmd, " ") + command := commandWithArguments[0] + arguments := commandWithArguments[1:] + + return exec.Command(command, arguments...) +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go new file mode 100644 index 0000000..0610902 --- /dev/null +++ b/internal/utils/utils_test.go @@ -0,0 +1,133 @@ +package utils + +import ( + "os/exec" + "testing" + + "github.com/grafviktor/goto/internal/model" + "github.com/grafviktor/goto/internal/utils/ssh" +) + +func TestCheckAppInstalled(t *testing.T) { + tests := []struct { + name string + appName string + expectedError bool + }{ + { + name: "Installed App", + appName: "echo", // Assuming 'echo' is always installed + expectedError: false, + }, + { + name: "Uninstalled App", + appName: "nonexistentapp", + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CheckAppInstalled(tt.appName) + + if tt.expectedError && err == nil { + t.Errorf("Expected an error, but got nil") + } + + if !tt.expectedError && err != nil { + t.Errorf("Expected no error, but got: %v", err) + } + }) + } +} + +func TestHostModelToOptionsAdaptor(t *testing.T) { + tests := []struct { + name string + host model.Host + expectedOptions []ssh.CommandLineOption + }{ + { + name: "Valid Host", + host: model.Host{ + Address: "example.com", + LoginName: "user", + RemotePort: "22", + PrivateKeyPath: "/path/to/private_key", + }, + expectedOptions: []ssh.CommandLineOption{ + ssh.OptionAddress{Value: "example.com"}, + ssh.OptionLoginName{Value: "user"}, + ssh.OptionRemotePort{Value: "22"}, + ssh.OptionPrivateKey{Value: "/path/to/private_key"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := HostModelToOptionsAdaptor(tt.host) + + if len(result) != len(tt.expectedOptions) { + t.Errorf("Expected %d options, but got %d", len(tt.expectedOptions), len(result)) + } + + for i := range result { + if result[i] != tt.expectedOptions[i] { + t.Errorf("Expected option %v, but got %v", tt.expectedOptions[i], result[i]) + } + } + }) + } +} + +func TestBuildProcess(t *testing.T) { + tests := []struct { + name string + cmd string + expectedCmd *exec.Cmd + }{ + { + name: "Simple Command", + cmd: "cd", + expectedCmd: exec.Command("cd"), + }, + { + name: "Command with Arguments", + cmd: "echo hello", + expectedCmd: exec.Command("echo", "hello"), + }, + { + name: "Empty Command", + cmd: "", + expectedCmd: nil, // Expecting nil as there is no valid command + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := BuildProcess(tt.cmd) + + switch { + case tt.expectedCmd == nil && result != nil: + t.Errorf("Expected nil, but got %+v", result) + + case tt.expectedCmd != nil && result == nil: + t.Errorf("Expected %+v, but got nil", tt.expectedCmd) + case tt.expectedCmd != nil && result != nil: + // Compare relevant fields of the Cmd struct + if tt.expectedCmd.Path != result.Path { + t.Errorf("Expected Path %s, but got %s", tt.expectedCmd.Path, result.Path) + } + if len(tt.expectedCmd.Args) != len(result.Args) { + t.Errorf("Expected %d arguments, but got %d", len(tt.expectedCmd.Args), len(result.Args)) + } + for i := range tt.expectedCmd.Args { + if tt.expectedCmd.Args[i] != result.Args[i] { + t.Errorf("Expected argument %s, but got %s", tt.expectedCmd.Args[i], result.Args[i]) + } + } + } + }) + } +}