From f8cc4bfc23fa4ec9edbcf5f4641f664e44ef9759 Mon Sep 17 00:00:00 2001 From: Pei-Tang Huang Date: Fri, 19 Sep 2025 23:41:23 +0800 Subject: [PATCH 1/6] feat: add SSH command parser with comprehensive flag support - Parse SSH commands from clipboard into domain.Server struct - Support standard SSH flags (-p, -i, -l, -A, -X, -Y, -C, -4, -6, etc.) - Support SSH -o options mapping to ssh_config fields - Handle multiline commands with backslash continuation - Extract lazyssh metadata (alias and tags) from comments - Ignore regular comments (lines starting with #) - Add comprehensive test coverage for various SSH command patterns --- internal/adapters/ui/ssh_parser.go | 968 ++++++++++++++++++++++++ internal/adapters/ui/ssh_parser_test.go | 719 ++++++++++++++++++ 2 files changed, 1687 insertions(+) create mode 100644 internal/adapters/ui/ssh_parser.go create mode 100644 internal/adapters/ui/ssh_parser_test.go diff --git a/internal/adapters/ui/ssh_parser.go b/internal/adapters/ui/ssh_parser.go new file mode 100644 index 0000000..eee66bc --- /dev/null +++ b/internal/adapters/ui/ssh_parser.go @@ -0,0 +1,968 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Adembc/lazyssh/internal/core/domain" +) + +// Log level constants +const ( + logVerbose = "VERBOSE" + logDebug = "DEBUG" + logDebug2 = "DEBUG2" + logDebug3 = "DEBUG3" + logQuiet = "QUIET" + sshUser = "user" +) + +// ParseSSHCommand parses an SSH command string into a domain.Server struct +// Supports standard SSH command arguments and -o options mapping to ssh_config +// Also supports multiline commands with backslash line continuation +func ParseSSHCommand(cmd string) (*domain.Server, error) { + // Remove leading/trailing whitespace first + cmd = strings.TrimSpace(cmd) + + // Extract alias and tags from comment if present (do this before preprocessing multiline) + var extractedAlias, extractedTags string + lines := strings.Split(cmd, "\n") + cmdLines := make([]string, 0, len(lines)) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + // Check for any comment line + if strings.HasPrefix(trimmed, "#") { + // Check if it's a lazyssh metadata comment + if strings.Contains(trimmed, "lazyssh-alias:") { + // Parse alias + parts := strings.Split(trimmed, "lazyssh-alias:") + if len(parts) >= 2 { + aliasAndTags := strings.TrimSpace(parts[1]) + // Check if tags are included + if tagIdx := strings.Index(aliasAndTags, " tags:"); tagIdx > 0 { + extractedAlias = strings.TrimSpace(aliasAndTags[:tagIdx]) + extractedTags = strings.TrimSpace(aliasAndTags[tagIdx+6:]) // 6 is length of " tags:" + } else { + extractedAlias = aliasAndTags + } + } + } + // Skip all comment lines (both metadata and regular comments) + continue + } + // Keep non-comment lines + cmdLines = append(cmdLines, line) + } + cmd = strings.Join(cmdLines, "\n") + + // Now preprocess multiline commands (handle backslash line continuation) + cmd = preprocessMultilineCommand(cmd) + cmd = strings.TrimSpace(cmd) + + // Check if it starts with "ssh" + if !strings.HasPrefix(cmd, "ssh ") && cmd != "ssh" { + return nil, fmt.Errorf("not a valid ssh command") + } + + // Split the command into parts, preserving quoted strings + parts, err := splitCommand(cmd) + if err != nil { + return nil, err + } + + if len(parts) < 2 { + return nil, fmt.Errorf("missing host specification") + } + + // Remove "ssh" from the beginning + parts = parts[1:] + + server := &domain.Server{} + + // Parse all parts: flags can appear before or after the destination + if err := parseSSHCommandParts(server, parts); err != nil { + return nil, err + } + + // Override alias and tags if extracted from comment + if extractedAlias != "" { + server.Alias = extractedAlias + } + if extractedTags != "" { + // Split tags by comma + server.Tags = strings.Split(extractedTags, ",") + // Trim spaces from each tag + for i, tag := range server.Tags { + server.Tags[i] = strings.TrimSpace(tag) + } + } + + // Set default port if not specified + if server.Port == 0 { + server.Port = 22 + } + + return server, nil +} + +// parseSSHCommandParts parses all SSH command parts including flags and destination +func parseSSHCommandParts(server *domain.Server, parts []string) error { + destIndex := -1 + flagIndices := make(map[int]bool) + var remoteCommandParts []string + + // First pass: find the destination (first non-flag argument) + for i := 0; i < len(parts); i++ { + part := parts[i] + if !strings.HasPrefix(part, "-") { + destIndex = i + break + } + // Skip flag values + consumed, _ := peekFlagConsumption(parts, i) + if consumed > 1 { + i += consumed - 1 + } + } + + if destIndex == -1 { + return fmt.Errorf("missing destination host") + } + + // Parse the destination + dest := parts[destIndex] + if strings.Contains(dest, "@") { + userHost := strings.SplitN(dest, "@", 2) + server.User = userHost[0] + dest = userHost[1] + } + + // Parse host:port format + if strings.Contains(dest, ":") && !strings.Contains(dest, "[") { + hostPort := strings.SplitN(dest, ":", 2) + server.Host = hostPort[0] + port, err := strconv.Atoi(hostPort[1]) + if err == nil { + server.Port = port + } + } else { + server.Host = dest + } + + // Second pass: parse all flags (before and after destination) and collect remote command + i := 0 + afterDestination := false + for i < len(parts) { + if i == destIndex { + // Skip the destination + flagIndices[i] = true + afterDestination = true + i++ + continue + } + + part := parts[i] + + // Check if this is a flag + if strings.HasPrefix(part, "-") { + // After destination, only parse known SSH flags + if afterDestination { + // Check if it's a known SSH flag + consumed, _ := peekFlagConsumption(parts, i) + if consumed > 0 { + // It's a known SSH flag, parse it + actualConsumed, err := parseFlag(server, parts, i) + if err != nil { + return err + } + // Mark all consumed indices as flag indices + for j := 0; j < actualConsumed; j++ { + flagIndices[i+j] = true + } + i += actualConsumed + } else { + // Unknown flag after destination - probably part of remote command + i++ + } + } else { + // Before destination, parse all flags + consumed, err := parseFlag(server, parts, i) + if err != nil { + return err + } + if consumed == 0 { + consumed = 1 + } + // Mark all consumed indices as flag indices + for j := 0; j < consumed; j++ { + flagIndices[i+j] = true + } + i += consumed + } + } else { + // This might be part of remote command + i++ + } + } + + // Collect remote command (anything that's not a flag or destination) + for i, part := range parts { + if !flagIndices[i] && i != destIndex { + remoteCommandParts = append(remoteCommandParts, part) + } + } + + if len(remoteCommandParts) > 0 { + server.RemoteCommand = strings.Join(remoteCommandParts, " ") + } + + // Set default alias if not set + if server.Alias == "" { + server.Alias = GenerateSmartAlias(server.Host, server.User, server.Port) + } + + return nil +} + +// peekFlagConsumption checks how many parts a flag would consume without modifying state +func peekFlagConsumption(parts []string, i int) (int, error) { + if i >= len(parts) { + return 0, nil + } + + part := parts[i] + + // Flags that consume 2 parts (flag + value) + twoPartFlags := map[string]bool{ + "-p": true, "-l": true, "-i": true, "-b": true, "-B": true, + "-J": true, "-W": true, "-L": true, "-R": true, "-D": true, + "-c": true, "-m": true, "-e": true, "-F": true, "-S": true, + "-O": true, "-o": true, + } + + if twoPartFlags[part] { + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing value after %s", part) + } + return 2, nil + } + + // Single-part SSH flags + singlePartFlags := map[string]bool{ + "-4": true, "-6": true, "-A": true, "-a": true, "-C": true, + "-f": true, "-g": true, "-k": true, "-K": true, "-M": true, + "-n": true, "-N": true, "-q": true, "-s": true, "-T": true, + "-t": true, "-v": true, "-V": true, "-x": true, "-X": true, + "-Y": true, + // Also include verbose variations + "-vv": true, "-vvv": true, + } + + if singlePartFlags[part] { + return 1, nil + } + + // Unknown flag - return 0 + return 0, nil +} + +// parseFlag parses a single flag and returns how many parts were consumed +func parseFlag(server *domain.Server, parts []string, i int) (int, error) { + part := parts[i] + + // Try parsing different categories of flags + if consumed, err := parseConnectionFlag(server, parts, i); err != nil { + return 0, err + } else if consumed > 0 { + return consumed, nil + } + + if consumed, err := parseAuthenticationFlag(server, parts, i); err != nil { + return 0, err + } else if consumed > 0 { + return consumed, nil + } + + if consumed, err := parseForwardingFlag(server, parts, i); err != nil { + return 0, err + } else if consumed > 0 { + return consumed, nil + } + + if consumed, err := parseProxyFlag(server, parts, i); err != nil { + return 0, err + } else if consumed > 0 { + return consumed, nil + } + + if consumed := parseLoggingFlag(server, parts, i); consumed > 0 { + return consumed, nil + } + + if consumed, err := parseMiscFlag(server, parts, i); err != nil { + return 0, err + } else if consumed > 0 { + return consumed, nil + } + + // Handle -o option specially + if part == "-o" { + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing option value after -o") + } + if err := parseSSHOption(server, parts[i+1]); err != nil { + return 0, fmt.Errorf("invalid SSH option: %w", err) + } + return 2, nil // -o consumes 2 parts: the flag and its value + } + + // Unknown flag - return 1 to consume only this flag + if strings.HasPrefix(part, "-") && len(part) > 1 { + return 1, nil + } + + return 0, nil +} + +// parseConnectionFlag parses connection-related flags +func parseConnectionFlag(server *domain.Server, parts []string, i int) (int, error) { + part := parts[i] + + switch part { + case "-p": + // Port + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing port value after -p") + } + port, err := strconv.Atoi(parts[i+1]) + if err != nil { + return 0, fmt.Errorf("invalid port value: %s", parts[i+1]) + } + server.Port = port + return 2, nil + + case "-4": + // Force IPv4 + server.AddressFamily = "inet" + return 1, nil + + case "-6": + // Force IPv6 + server.AddressFamily = "inet6" + return 1, nil + + case "-b": + // Bind address + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing bind address after -b") + } + server.BindAddress = parts[i+1] + return 2, nil + + case "-B": + // Bind interface (OpenSSH 8.9+) + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing bind interface after -B") + } + server.BindInterface = parts[i+1] + return 2, nil + + case "-C": + // Enable compression + server.Compression = sshYes + return 1, nil + + case "-N": + // No remote command + server.SessionType = "none" + return 1, nil + + case "-T": + // Disable pseudo-terminal allocation + server.RequestTTY = sshNo + return 1, nil + + case "-t": + // Force pseudo-terminal allocation + server.RequestTTY = sshYes + return 1, nil + + case "-n": + // Redirect stdin from /dev/null (batch mode) + server.BatchMode = sshYes + return 1, nil + + case "-s": + // Subsystem + server.SessionType = "subsystem" + return 1, nil + + case "-f": + // Go to background (runtime behavior, not a config option) + return 1, nil + + case "-g": + // Allow remote hosts to connect to forwarded ports + server.GatewayPorts = sshYes + return 1, nil + } + + return 0, nil +} + +// parseAuthenticationFlag parses authentication-related flags +func parseAuthenticationFlag(server *domain.Server, parts []string, i int) (int, error) { + part := parts[i] + + switch part { + case "-l": + // Login name (user) + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing user value after -l") + } + server.User = parts[i+1] + return 2, nil + + case "-i": + // Identity file + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing identity file after -i") + } + if server.IdentityFiles == nil { + server.IdentityFiles = []string{} + } + server.IdentityFiles = append(server.IdentityFiles, parts[i+1]) + return 2, nil + + case "-A": + // Forward agent + server.ForwardAgent = sshYes + return 1, nil + + case "-a": + // Disable agent forwarding + server.ForwardAgent = sshNo + return 1, nil + + case "-k": + // Disable GSSAPI authentication + // We'll skip GSSAPI-related options + return 1, nil + + case "-K": + // Enable GSSAPI authentication and forwarding + // We'll skip GSSAPI-related options + return 1, nil + } + + return 0, nil +} + +// parseForwardingFlag parses forwarding-related flags +func parseForwardingFlag(server *domain.Server, parts []string, i int) (int, error) { + part := parts[i] + + switch part { + case "-L": + // Local port forwarding + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing local forward specification after -L") + } + if server.LocalForward == nil { + server.LocalForward = []string{} + } + server.LocalForward = append(server.LocalForward, parts[i+1]) + return 2, nil + + case "-R": + // Remote port forwarding + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing remote forward specification after -R") + } + if server.RemoteForward == nil { + server.RemoteForward = []string{} + } + server.RemoteForward = append(server.RemoteForward, parts[i+1]) + return 2, nil + + case "-D": + // Dynamic port forwarding (SOCKS) + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing dynamic forward specification after -D") + } + if server.DynamicForward == nil { + server.DynamicForward = []string{} + } + server.DynamicForward = append(server.DynamicForward, parts[i+1]) + return 2, nil + + case "-X": + // Enable X11 forwarding + server.ForwardX11 = sshYes + return 1, nil + + case "-x": + // Disable X11 forwarding + server.ForwardX11 = sshNo + return 1, nil + + case "-Y": + // Enable trusted X11 forwarding + server.ForwardX11 = sshYes + server.ForwardX11Trusted = sshYes + return 1, nil + } + + return 0, nil +} + +// parseProxyFlag parses proxy-related flags +func parseProxyFlag(server *domain.Server, parts []string, i int) (int, error) { + part := parts[i] + + switch part { + case "-J": + // ProxyJump + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing proxy jump value after -J") + } + server.ProxyJump = parts[i+1] + return 2, nil + + case "-W": + // ProxyCommand (stdio forwarding) + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing forward specification after -W") + } + // -W is typically used with ProxyCommand + server.ProxyCommand = fmt.Sprintf("ssh -W %s %%h:%%p", parts[i+1]) + return 2, nil + } + + return 0, nil +} + +// parseLoggingFlag parses logging and debugging flags +func parseLoggingFlag(server *domain.Server, parts []string, i int) int { + part := parts[i] + + switch part { + case "-v": + // Verbose (can be repeated for more verbosity) + switch server.LogLevel { + case "": + server.LogLevel = logVerbose + case logVerbose: + server.LogLevel = logDebug + case logDebug: + server.LogLevel = logDebug2 + case logDebug2: + server.LogLevel = logDebug3 + } + return 1 + + case "-vv": + // Double verbose + server.LogLevel = logDebug + return 1 + + case "-vvv": + // Triple verbose + server.LogLevel = logDebug2 + return 1 + + case "-q": + // Quiet mode + server.LogLevel = logQuiet + return 1 + } + + return 0 +} + +// parseMiscFlag parses miscellaneous flags +func parseMiscFlag(server *domain.Server, parts []string, i int) (int, error) { + part := parts[i] + + switch part { + case "-c": + // Cipher specification + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing cipher specification after -c") + } + server.Ciphers = parts[i+1] + return 2, nil + + case "-m": + // MAC specification + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing MAC specification after -m") + } + server.MACs = parts[i+1] + return 2, nil + + case "-e": + // Escape character + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing escape character after -e") + } + server.EscapeChar = parts[i+1] + return 2, nil + + case "-F": + // Config file (skip for now, as we don't process external configs) + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing config file after -F") + } + // Skip the config file path + return 2, nil + + case "-M": + // ControlMaster mode + server.ControlMaster = sshYes + return 1, nil + + case "-S": + // ControlPath + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing control path after -S") + } + server.ControlPath = parts[i+1] + return 2, nil + + case "-O": + // Control command (check, forward, cancel, exit, stop) + if i+1 >= len(parts) { + return 0, fmt.Errorf("missing control command after -O") + } + // Skip control command for now + return 2, nil + } + + return 0, nil +} + +// parseSSHOption parses a single SSH -o option +func parseSSHOption(server *domain.Server, option string) error { + // Split on the first '=' + parts := strings.SplitN(option, "=", 2) + if len(parts) != 2 { + // Some options use space separation + parts = strings.SplitN(option, " ", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid option format: %s", option) + } + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Parse different categories of options + if err := parseConnectionOption(server, key, value); err != nil { + return err + } + if err := parseAuthOption(server, key, value); err != nil { + return err + } + if err := parseForwardingOption(server, key, value); err != nil { + return err + } + if err := parseSecurityOption(server, key, value); err != nil { + return err + } + if err := parseMiscOption(server, key, value); err != nil { + return err + } + + return nil +} + +// parseConnectionOption parses connection-related SSH options +func parseConnectionOption(server *domain.Server, key, value string) error { + switch strings.ToLower(key) { + case "hostname": + server.Host = value + case "port": + port, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid port: %s", value) + } + server.Port = port + case sshUser: + server.User = value + case "connecttimeout": + server.ConnectTimeout = value + case "serveraliveinterval": + server.ServerAliveInterval = value + case "serveralivecountmax": + server.ServerAliveCountMax = value + case "tcpkeepalive": + server.TCPKeepAlive = value + case "compression": + server.Compression = value + case "addressfamily": + server.AddressFamily = value + case "bindaddress": + server.BindAddress = value + case "bindinterface": + server.BindInterface = value + case "connectionattempts": + server.ConnectionAttempts = value + case "ipqos": + server.IPQoS = value + case "batchmode": + server.BatchMode = value + case "requesttty": + server.RequestTTY = value + case "sessiontype": + server.SessionType = value + case "remotecommand": + server.RemoteCommand = value + case "localcommand": + server.LocalCommand = value + case "permitlocalcommand": + server.PermitLocalCommand = value + case "escapechar": + server.EscapeChar = value + case "loglevel": + server.LogLevel = value + } + return nil +} + +// parseAuthOption parses authentication-related SSH options +func parseAuthOption(server *domain.Server, key, value string) error { + switch strings.ToLower(key) { + case "identityfile": + if server.IdentityFiles == nil { + server.IdentityFiles = []string{} + } + server.IdentityFiles = append(server.IdentityFiles, value) + case "passwordauthentication": + server.PasswordAuthentication = value + case "pubkeyauthentication": + server.PubkeyAuthentication = value + case "kbdinteractiveauthentication": + server.KbdInteractiveAuthentication = value + case "preferredauthentications": + server.PreferredAuthentications = value + case "identitiesonly": + server.IdentitiesOnly = value + case "addkeystoagent": + server.AddKeysToAgent = value + case "identityagent": + server.IdentityAgent = value + case "numberofpasswordprompts": + server.NumberOfPasswordPrompts = value + case "pubkeyacceptedalgorithms": + server.PubkeyAcceptedAlgorithms = value + case "hostbasedacceptedalgorithms": + server.HostbasedAcceptedAlgorithms = value + } + return nil +} + +// parseForwardingOption parses forwarding-related SSH options +func parseForwardingOption(server *domain.Server, key, value string) error { + switch strings.ToLower(key) { + case "localforward": + if server.LocalForward == nil { + server.LocalForward = []string{} + } + server.LocalForward = append(server.LocalForward, value) + case "remoteforward": + if server.RemoteForward == nil { + server.RemoteForward = []string{} + } + server.RemoteForward = append(server.RemoteForward, value) + case "dynamicforward": + if server.DynamicForward == nil { + server.DynamicForward = []string{} + } + server.DynamicForward = append(server.DynamicForward, value) + case "forwardagent": + server.ForwardAgent = value + case "forwardx11": + server.ForwardX11 = value + case "forwardx11trusted": + server.ForwardX11Trusted = value + case "gatewayports": + server.GatewayPorts = value + case "clearallforwardings": + server.ClearAllForwardings = value + case "exitonforwardfailure": + server.ExitOnForwardFailure = value + case "proxyjump": + server.ProxyJump = value + case "proxycommand": + server.ProxyCommand = value + } + return nil +} + +// parseSecurityOption parses security-related SSH options +func parseSecurityOption(server *domain.Server, key, value string) error { + switch strings.ToLower(key) { + case "stricthostkeychecking": + server.StrictHostKeyChecking = value + case "userknownhostsfile": + server.UserKnownHostsFile = value + case "checkhostip": + server.CheckHostIP = value + case "fingerprinthash": + server.FingerprintHash = value + case "hostkeyalgorithms": + server.HostKeyAlgorithms = value + case "ciphers": + server.Ciphers = value + case "macs": + server.MACs = value + case "kexalgorithms": + server.KexAlgorithms = value + case "verifyhostkeydns": + server.VerifyHostKeyDNS = value + case "updatehostkeys": + server.UpdateHostKeys = value + case "hashknownhosts": + server.HashKnownHosts = value + case "visualhostkey": + server.VisualHostKey = value + } + return nil +} + +// parseMiscOption parses miscellaneous SSH options +func parseMiscOption(server *domain.Server, key, value string) error { + switch strings.ToLower(key) { + case "controlmaster": + server.ControlMaster = value + case "controlpath": + server.ControlPath = value + case "controlpersist": + server.ControlPersist = value + case "sendenv": + if server.SendEnv == nil { + server.SendEnv = []string{} + } + server.SendEnv = append(server.SendEnv, value) + case "setenv": + if server.SetEnv == nil { + server.SetEnv = []string{} + } + server.SetEnv = append(server.SetEnv, value) + case "canonicalizehostname": + server.CanonicalizeHostname = value + case "canonicaldomains": + server.CanonicalDomains = value + case "canonicalizefallbacklocal": + server.CanonicalizeFallbackLocal = value + case "canonicalizemaxdots": + server.CanonicalizeMaxDots = value + case "canonicalizepermittedcnames": + server.CanonicalizePermittedCNAMEs = value + } + return nil +} + +// preprocessMultilineCommand handles backslash line continuation +// It joins lines that end with backslash into a single line +func preprocessMultilineCommand(cmd string) string { + // Split by newlines to handle each line + lines := strings.Split(cmd, "\n") + var result strings.Builder + + for i := 0; i < len(lines); i++ { + line := lines[i] + + // Check if line ends with backslash (line continuation) + trimmed := strings.TrimSpace(line) + if strings.HasSuffix(trimmed, "\\") { + // Remove the trailing backslash and add the content + content := strings.TrimSuffix(trimmed, "\\") + result.WriteString(strings.TrimSpace(content)) + // Add a space to separate from next line's content + if i < len(lines)-1 { + result.WriteString(" ") + } + } else { + // Normal line without continuation + result.WriteString(strings.TrimSpace(line)) + // Only add space if there are more lines and this isn't empty + if i < len(lines)-1 && strings.TrimSpace(line) != "" { + result.WriteString(" ") + } + } + } + + return strings.TrimSpace(result.String()) +} + +// splitCommand splits a command string into parts, preserving quoted strings +func splitCommand(cmd string) ([]string, error) { + var parts []string + var current strings.Builder + var inQuote rune + var escaped bool + + for _, r := range cmd { + if escaped { + current.WriteRune(r) + escaped = false + continue + } + + if r == '\\' { + escaped = true + continue + } + + if inQuote != 0 { + if r == inQuote { + inQuote = 0 + } else { + current.WriteRune(r) + } + continue + } + + if r == '"' || r == '\'' { + inQuote = r + continue + } + + if r == ' ' || r == '\t' { + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + continue + } + + current.WriteRune(r) + } + + if inQuote != 0 { + return nil, fmt.Errorf("unclosed quote in command") + } + + if current.Len() > 0 { + parts = append(parts, current.String()) + } + + return parts, nil +} diff --git a/internal/adapters/ui/ssh_parser_test.go b/internal/adapters/ui/ssh_parser_test.go new file mode 100644 index 0000000..c9dc9cb --- /dev/null +++ b/internal/adapters/ui/ssh_parser_test.go @@ -0,0 +1,719 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "testing" + + "github.com/Adembc/lazyssh/internal/core/domain" +) + +const ( + testHost = "example.com" + testUser = "user" +) + +func TestParseSSHCommand(t *testing.T) { + tests := []struct { + name string + cmd string + wantErr bool + check func(t *testing.T, server interface{}) + }{ + { + name: "basic ssh command", + cmd: "ssh user@" + testHost, + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.User != testUser { + t.Errorf("expected user '%s', got %s", testUser, server.User) + } + if server.Host != testHost { + t.Errorf("expected host '%s', got %s", testHost, server.Host) + } + }, + }, + { + name: "ssh with port", + cmd: "ssh -p 2222 user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.Port != 2222 { + t.Errorf("expected port 2222, got %d", server.Port) + } + }, + }, + { + name: "ssh with port after host", + cmd: "ssh user@prod-server-01.example.com -p 2222", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.Port != 2222 { + t.Errorf("expected port 2222, got %d", server.Port) + } + if server.Host != "prod-server-01.example.com" { + t.Errorf("expected host prod-server-01.example.com, got %s", server.Host) + } + if server.User != "user" { + t.Errorf("expected user 'user', got %s", server.User) + } + }, + }, + { + name: "ssh with port after host and remote command", + cmd: "ssh user@example.com -p 2222 ls -la", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.Port != 2222 { + t.Errorf("expected port 2222, got %d", server.Port) + } + if server.RemoteCommand != "ls -la" { + t.Errorf("expected remote command 'ls -la', got %s", server.RemoteCommand) + } + }, + }, + { + name: "ssh with mixed flags before and after host", + cmd: "ssh -i ~/.ssh/id_rsa user@example.com -p 2222 -v", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.Port != 2222 { + t.Errorf("expected port 2222, got %d", server.Port) + } + if len(server.IdentityFiles) != 1 || server.IdentityFiles[0] != "~/.ssh/id_rsa" { + t.Errorf("expected identity file '~/.ssh/id_rsa', got %v", server.IdentityFiles) + } + if server.LogLevel != "VERBOSE" { + t.Errorf("expected log level 'VERBOSE', got %s", server.LogLevel) + } + }, + }, + { + name: "ssh with identity file", + cmd: "ssh -i ~/.ssh/id_rsa user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if len(server.IdentityFiles) != 1 || server.IdentityFiles[0] != "~/.ssh/id_rsa" { + t.Errorf("expected identity file '~/.ssh/id_rsa', got %v", server.IdentityFiles) + } + }, + }, + { + name: "ssh with multiple identity files", + cmd: "ssh -i ~/.ssh/id_rsa -i ~/.ssh/id_ed25519 user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if len(server.IdentityFiles) != 2 { + t.Errorf("expected 2 identity files, got %d", len(server.IdentityFiles)) + } + }, + }, + { + name: "ssh with proxy jump", + cmd: "ssh -J bastion@jump.example.com user@internal.example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.ProxyJump != "bastion@jump.example.com" { + t.Errorf("expected ProxyJump 'bastion@jump.example.com', got %s", server.ProxyJump) + } + }, + }, + { + name: "ssh with local forwarding", + cmd: "ssh -L 8080:localhost:80 user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if len(server.LocalForward) != 1 || server.LocalForward[0] != "8080:localhost:80" { + t.Errorf("expected local forward '8080:localhost:80', got %v", server.LocalForward) + } + }, + }, + { + name: "ssh with remote forwarding", + cmd: "ssh -R 9090:localhost:9090 user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if len(server.RemoteForward) != 1 || server.RemoteForward[0] != "9090:localhost:9090" { + t.Errorf("expected remote forward '9090:localhost:9090', got %v", server.RemoteForward) + } + }, + }, + { + name: "ssh with dynamic forwarding", + cmd: "ssh -D 1080 user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if len(server.DynamicForward) != 1 || server.DynamicForward[0] != "1080" { + t.Errorf("expected dynamic forward '1080', got %v", server.DynamicForward) + } + }, + }, + { + name: "ssh with agent forwarding", + cmd: "ssh -A user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.ForwardAgent != sshYes { + t.Errorf("expected ForwardAgent 'yes', got %s", server.ForwardAgent) + } + }, + }, + { + name: "ssh with X11 forwarding", + cmd: "ssh -X user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.ForwardX11 != sshYes { + t.Errorf("expected ForwardX11 'yes', got %s", server.ForwardX11) + } + }, + }, + { + name: "ssh with trusted X11 forwarding", + cmd: "ssh -Y user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.ForwardX11 != sshYes { + t.Errorf("expected ForwardX11 'yes', got %s", server.ForwardX11) + } + if server.ForwardX11Trusted != sshYes { + t.Errorf("expected ForwardX11Trusted 'yes', got %s", server.ForwardX11Trusted) + } + }, + }, + { + name: "ssh with compression", + cmd: "ssh -C user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.Compression != sshYes { + t.Errorf("expected Compression 'yes', got %s", server.Compression) + } + }, + }, + { + name: "ssh with -o options", + cmd: "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.StrictHostKeyChecking != sshNo { + t.Errorf("expected StrictHostKeyChecking 'no', got %s", server.StrictHostKeyChecking) + } + if server.ConnectTimeout != "10" { + t.Errorf("expected ConnectTimeout '10', got %s", server.ConnectTimeout) + } + }, + }, + { + name: "ssh with complex forwarding", + cmd: "ssh -L 8080:localhost:80 -L 3306:db:3306 -R 9090:localhost:9090 -D 1080 user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if len(server.LocalForward) != 2 { + t.Errorf("expected 2 local forwards, got %d", len(server.LocalForward)) + } + if len(server.RemoteForward) != 1 { + t.Errorf("expected 1 remote forward, got %d", len(server.RemoteForward)) + } + if len(server.DynamicForward) != 1 { + t.Errorf("expected 1 dynamic forward, got %d", len(server.DynamicForward)) + } + }, + }, + { + name: "ssh with remote command", + cmd: `ssh user@example.com "ls -la /tmp"`, + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.RemoteCommand != "ls -la /tmp" { + t.Errorf("expected RemoteCommand 'ls -la /tmp', got %s", server.RemoteCommand) + } + }, + }, + { + name: "ssh with login name flag", + cmd: "ssh -l admin example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.User != "admin" { + t.Errorf("expected user 'admin', got %s", server.User) + } + }, + }, + { + name: "ssh with IPv4 only", + cmd: "ssh -4 user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.AddressFamily != "inet" { + t.Errorf("expected AddressFamily 'inet', got %s", server.AddressFamily) + } + }, + }, + { + name: "ssh with IPv6 only", + cmd: "ssh -6 user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.AddressFamily != "inet6" { + t.Errorf("expected AddressFamily 'inet6', got %s", server.AddressFamily) + } + }, + }, + { + name: "ssh with control master", + cmd: "ssh -M -S /tmp/ssh_control_%h_%p user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.ControlMaster != sshYes { + t.Errorf("expected ControlMaster 'yes', got %s", server.ControlMaster) + } + if server.ControlPath != "/tmp/ssh_control_%h_%p" { + t.Errorf("expected ControlPath '/tmp/ssh_control_%%h_%%p', got %s", server.ControlPath) + } + }, + }, + { + name: "ssh with verbose mode", + cmd: "ssh -v user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.LogLevel != "VERBOSE" { + t.Errorf("expected LogLevel 'VERBOSE', got %s", server.LogLevel) + } + }, + }, + { + name: "ssh with double verbose", + cmd: "ssh -vv user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.LogLevel != "DEBUG" { + t.Errorf("expected LogLevel 'DEBUG', got %s", server.LogLevel) + } + }, + }, + { + name: "ssh with quiet mode", + cmd: "ssh -q user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.LogLevel != "QUIET" { + t.Errorf("expected LogLevel 'QUIET', got %s", server.LogLevel) + } + }, + }, + { + name: "ssh with batch mode", + cmd: "ssh -n user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.BatchMode != sshYes { + t.Errorf("expected BatchMode 'yes', got %s", server.BatchMode) + } + }, + }, + { + name: "ssh with TTY allocation", + cmd: "ssh -t user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.RequestTTY != sshYes { + t.Errorf("expected RequestTTY 'yes', got %s", server.RequestTTY) + } + }, + }, + { + name: "ssh with no TTY", + cmd: "ssh -T user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.RequestTTY != sshNo { + t.Errorf("expected RequestTTY 'no', got %s", server.RequestTTY) + } + }, + }, + { + name: "ssh with gateway ports", + cmd: "ssh -g user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.GatewayPorts != sshYes { + t.Errorf("expected GatewayPorts 'yes', got %s", server.GatewayPorts) + } + }, + }, + { + name: "ssh with cipher spec", + cmd: "ssh -c aes256-ctr user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.Ciphers != "aes256-ctr" { + t.Errorf("expected Ciphers 'aes256-ctr', got %s", server.Ciphers) + } + }, + }, + { + name: "ssh with MAC spec", + cmd: "ssh -m hmac-sha2-256 user@example.com", + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.MACs != "hmac-sha2-256" { + t.Errorf("expected MACs 'hmac-sha2-256', got %s", server.MACs) + } + }, + }, + { + name: "ssh without user", + cmd: "ssh " + testHost, + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + if server.Host != testHost { + t.Errorf("expected host '%s', got %s", testHost, server.Host) + } + // Smart alias generation simplifies example.com to example + expectedAlias := "example" + if server.Alias != expectedAlias { + t.Errorf("expected alias '%s', got %s", expectedAlias, server.Alias) + } + // Default port should be 22 + if server.Port != 22 { + t.Errorf("expected default port 22, got %d", server.Port) + } + }, + }, + { + name: "not an ssh command", + cmd: "ls -la", + wantErr: true, + }, + { + name: "empty command", + cmd: "", + wantErr: true, + }, + { + name: "ssh without host", + cmd: "ssh", + wantErr: true, + }, + { + name: "ssh with invalid port", + cmd: "ssh -p abc user@example.com", + wantErr: true, + }, + { + name: "ssh with lazyssh alias comment", + cmd: `# lazyssh-alias:myserver +ssh -p 2222 user@example.com`, + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + // Should use the alias from the comment + if server.Alias != "myserver" { + t.Errorf("expected alias 'myserver', got %s", server.Alias) + } + if server.Host != testHost { + t.Errorf("expected host '%s', got %s", testHost, server.Host) + } + if server.User != testUser { + t.Errorf("expected user '%s', got %s", testUser, server.User) + } + if server.Port != 2222 { + t.Errorf("expected port 2222, got %d", server.Port) + } + }, + }, + { + name: "ssh with lazyssh alias and tags comment", + cmd: `# lazyssh-alias:prod-server tags:production,web,critical +ssh -p 443 admin@api.example.com`, + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + // Should use the alias and tags from the comment + if server.Alias != "prod-server" { + t.Errorf("expected alias 'prod-server', got %s", server.Alias) + } + expectedTags := []string{"production", "web", "critical"} + if len(server.Tags) != len(expectedTags) { + t.Errorf("expected %d tags, got %d", len(expectedTags), len(server.Tags)) + } else { + for i, tag := range expectedTags { + if server.Tags[i] != tag { + t.Errorf("expected tag[%d] = '%s', got '%s'", i, tag, server.Tags[i]) + } + } + } + if server.Host != "api.example.com" { + t.Errorf("expected host 'api.example.com', got %s", server.Host) + } + if server.User != "admin" { + t.Errorf("expected user 'admin', got %s", server.User) + } + if server.Port != 443 { + t.Errorf("expected port 443, got %d", server.Port) + } + }, + }, + { + name: "ssh with regular comments (should be ignored)", + cmd: `# This is a regular comment +# Another comment line +ssh -i ~/.ssh/arcsight.fia.test.pem ec2-user@arcsight.fia.test`, + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + // Regular comments should be ignored + if server.Host != "arcsight.fia.test" { + t.Errorf("expected host 'arcsight.fia.test', got %s", server.Host) + } + if server.User != "ec2-user" { + t.Errorf("expected user 'ec2-user', got %s", server.User) + } + if len(server.IdentityFiles) != 1 || server.IdentityFiles[0] != "~/.ssh/arcsight.fia.test.pem" { + t.Errorf("expected identity file '~/.ssh/arcsight.fia.test.pem', got %v", server.IdentityFiles) + } + }, + }, + { + name: "ssh with mixed comments and metadata", + cmd: `# Regular comment +# lazyssh-alias:test-server tags:test +# Another regular comment +ssh user@example.com`, + check: func(t *testing.T, s interface{}) { + server := s.(*domain.Server) + // Should extract metadata from lazyssh comment + if server.Alias != "test-server" { + t.Errorf("expected alias 'test-server', got %s", server.Alias) + } + if len(server.Tags) != 1 || server.Tags[0] != "test" { + t.Errorf("expected tag 'test', got %v", server.Tags) + } + if server.Host != "example.com" { + t.Errorf("expected host 'example.com', got %s", server.Host) + } + if server.User != "user" { + t.Errorf("expected user 'user', got %s", server.User) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server, err := ParseSSHCommand(tt.cmd) + if (err != nil) != tt.wantErr { + t.Errorf("ParseSSHCommand() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && tt.check != nil { + tt.check(t, server) + } + }) + } +} + +func TestPreprocessMultilineCommand(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "single line command", + input: "ssh user@example.com", + want: "ssh user@example.com", + }, + { + name: "multiline with backslash", + input: `ssh \ + -p 2222 \ + -i ~/.ssh/id_rsa \ + user@example.com`, + want: "ssh -p 2222 -i ~/.ssh/id_rsa user@example.com", + }, + { + name: "multiline with options", + input: `ssh \ + -o StrictHostKeyChecking=no \ + -o ConnectTimeout=10 \ + -L 8080:localhost:80 \ + user@example.com`, + want: "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 -L 8080:localhost:80 user@example.com", + }, + { + name: "multiline with mixed indentation", + input: `ssh \ + -p 2222 \ + -i ~/.ssh/id_rsa \ + user@example.com`, + want: "ssh -p 2222 -i ~/.ssh/id_rsa user@example.com", + }, + { + name: "multiline with empty lines", + input: `ssh \ + -p 2222 \ + + -i ~/.ssh/id_rsa \ + user@example.com`, + want: "ssh -p 2222 -i ~/.ssh/id_rsa user@example.com", + }, + { + name: "complex multiline command", + input: `ssh \ + -J bastion@jump.example.com \ + -o ProxyCommand="ssh -W %h:%p proxy" \ + -L 8080:localhost:80 \ + -L 3306:db:3306 \ + -R 9090:localhost:9090 \ + -D 1080 \ + admin@internal.example.com`, + want: `ssh -J bastion@jump.example.com -o ProxyCommand="ssh -W %h:%p proxy" -L 8080:localhost:80 -L 3306:db:3306 -R 9090:localhost:9090 -D 1080 admin@internal.example.com`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := preprocessMultilineCommand(tt.input) + if got != tt.want { + t.Errorf("preprocessMultilineCommand() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseSSHCommandMultiline(t *testing.T) { + tests := []struct { + name string + cmd string + wantErr bool + check func(t *testing.T, server *domain.Server) + }{ + { + name: "multiline ssh command", + cmd: `ssh \ + -p 2222 \ + -i ~/.ssh/id_rsa \ + user@example.com`, + check: func(t *testing.T, server *domain.Server) { + if server.Port != 2222 { + t.Errorf("expected port 2222, got %d", server.Port) + } + if len(server.IdentityFiles) != 1 || server.IdentityFiles[0] != "~/.ssh/id_rsa" { + t.Errorf("expected identity file '~/.ssh/id_rsa', got %v", server.IdentityFiles) + } + if server.User != testUser { + t.Errorf("expected user '%s', got %s", testUser, server.User) + } + if server.Host != "example.com" { + t.Errorf("expected host 'example.com', got %s", server.Host) + } + }, + }, + { + name: "multiline with multiple forwards", + cmd: `ssh \ + -L 8080:localhost:80 \ + -L 3306:db:3306 \ + -R 9090:localhost:9090 \ + -D 1080 \ + user@example.com`, + check: func(t *testing.T, server *domain.Server) { + if len(server.LocalForward) != 2 { + t.Errorf("expected 2 local forwards, got %d", len(server.LocalForward)) + } + if len(server.RemoteForward) != 1 { + t.Errorf("expected 1 remote forward, got %d", len(server.RemoteForward)) + } + if len(server.DynamicForward) != 1 { + t.Errorf("expected 1 dynamic forward, got %d", len(server.DynamicForward)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server, err := ParseSSHCommand(tt.cmd) + if (err != nil) != tt.wantErr { + t.Errorf("ParseSSHCommand() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && tt.check != nil { + tt.check(t, server) + } + }) + } +} + +func TestSplitCommand(t *testing.T) { + tests := []struct { + name string + cmd string + want []string + wantErr bool + }{ + { + name: "simple command", + cmd: "ssh user@example.com", + want: []string{"ssh", "user@example.com"}, + }, + { + name: "command with quoted string", + cmd: `ssh user@example.com "ls -la"`, + want: []string{"ssh", "user@example.com", "ls -la"}, + }, + { + name: "command with single quotes", + cmd: `ssh user@example.com 'echo "hello world"'`, + want: []string{"ssh", "user@example.com", `echo "hello world"`}, + }, + { + name: "command with escaped spaces", + cmd: `ssh user@example.com file\ with\ spaces.txt`, + want: []string{"ssh", "user@example.com", "file with spaces.txt"}, + }, + { + name: "mixed quotes", + cmd: `ssh -o "ProxyCommand=ssh -W %h:%p bastion" user@example.com`, + want: []string{"ssh", "-o", "ProxyCommand=ssh -W %h:%p bastion", "user@example.com"}, + }, + { + name: "unclosed quote", + cmd: `ssh user@example.com "unclosed`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := splitCommand(tt.cmd) + if (err != nil) != tt.wantErr { + t.Errorf("splitCommand() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if len(got) != len(tt.want) { + t.Errorf("splitCommand() got %d parts, want %d", len(got), len(tt.want)) + return + } + for i, part := range got { + if part != tt.want[i] { + t.Errorf("splitCommand() part[%d] = %q, want %q", i, part, tt.want[i]) + } + } + } + }) + } +} From 9169f0782f761395bc324a35d9958da5e736bf21 Mon Sep 17 00:00:00 2001 From: Pei-Tang Huang Date: Fri, 19 Sep 2025 23:41:37 +0800 Subject: [PATCH 2/6] feat: add alias generation and deduplication utilities - GenerateUniqueAlias: Handle duplicate aliases with smart numbering - Extract base name from aliases with existing suffixes (e.g., "123_1" -> "123") - Find highest suffix and increment (e.g., "123_1" -> "123_2", not "123_1_1") - GenerateSmartAlias: Create intelligent aliases from host/user/port - Simplify domain names (remove www, extract meaningful parts) - Handle IP addresses appropriately - Skip common usernames (root, ubuntu, ec2-user, centos, azureuser, etc.) - Append non-standard ports to alias - Add comprehensive test coverage for edge cases --- internal/adapters/ui/alias_utils.go | 151 ++++++++++++++++ internal/adapters/ui/alias_utils_test.go | 220 +++++++++++++++++++++++ internal/adapters/ui/utils_alias_test.go | 94 ++++++++++ 3 files changed, 465 insertions(+) create mode 100644 internal/adapters/ui/alias_utils.go create mode 100644 internal/adapters/ui/alias_utils_test.go create mode 100644 internal/adapters/ui/utils_alias_test.go diff --git a/internal/adapters/ui/alias_utils.go b/internal/adapters/ui/alias_utils.go new file mode 100644 index 0000000..6434fa5 --- /dev/null +++ b/internal/adapters/ui/alias_utils.go @@ -0,0 +1,151 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "fmt" + "strconv" + "strings" +) + +// GenerateUniqueAlias generates a unique alias by appending a suffix if necessary +func GenerateUniqueAlias(baseAlias string, existingAliases []string) string { + // Build a map for faster lookup + aliasMap := make(map[string]bool, len(existingAliases)) + for _, alias := range existingAliases { + aliasMap[alias] = true + } + + // If the base alias is unique, return it + if !aliasMap[baseAlias] { + return baseAlias + } + + // Extract the base name and current suffix (if any) + // e.g., "server_1" -> base: "server", suffix: 1 + // e.g., "server" -> base: "server", suffix: 0 + baseName := baseAlias + + // Check if the alias already has a numeric suffix + if lastUnderscore := strings.LastIndex(baseAlias, "_"); lastUnderscore != -1 { + possibleSuffix := baseAlias[lastUnderscore+1:] + if _, err := strconv.Atoi(possibleSuffix); err == nil { + // It has a valid numeric suffix + baseName = baseAlias[:lastUnderscore] + } + } + + // Find the highest suffix number for this base name + maxSuffix := 0 + basePattern := baseName + "_" + + // Check all existing aliases to find the max suffix + for _, alias := range existingAliases { + if alias == baseName { + // The base name without suffix exists, so we need at least _1 + if maxSuffix < 0 { + maxSuffix = 0 + } + } else if strings.HasPrefix(alias, basePattern) { + suffixStr := strings.TrimPrefix(alias, basePattern) + if suffix, err := strconv.Atoi(suffixStr); err == nil && suffix > maxSuffix { + maxSuffix = suffix + } + } + } + + // Return the base name with the next available suffix + return fmt.Sprintf("%s_%d", baseName, maxSuffix+1) +} + +// GenerateSmartAlias generates a smart alias from host, user, and port +// It simplifies domain names and handles IP addresses intelligently +func GenerateSmartAlias(host, user string, port int) string { + alias := host + + // Simplify common domain patterns + // Remove www. prefix + alias = strings.TrimPrefix(alias, "www.") + + // For FQDN, extract the meaningful part + // e.g., server.example.com -> server or example + if strings.Contains(alias, ".") && !IsIPAddress(alias) { + parts := strings.Split(alias, ".") + // If it has subdomain, use the subdomain + if len(parts) > 2 { + // e.g., api.github.com -> api.github + // or dev.server.example.com -> dev.server + if parts[0] != "www" { + alias = strings.Join(parts[:2], ".") + } else { + alias = parts[1] // Skip www + } + } else if len(parts) == 2 { + // Simple domain like example.com -> example + alias = parts[0] + } + } + + // Optionally prepend user if it's not a common one + if user != "" && !isCommonUser(user) { + alias = fmt.Sprintf("%s@%s", user, alias) + } + + // Append port if non-standard + if port != 0 && port != 22 { + alias = fmt.Sprintf("%s:%d", alias, port) + } + + return alias +} + +// isCommonUser checks if a username is a common default username +func isCommonUser(user string) bool { + // Common usernames that don't need to be included in alias + // These are default users for various cloud providers and Linux distributions + commonUsers := map[string]bool{ + // Common administrative users + "root": true, + "admin": true, + "administrator": true, + + // Linux distribution default users + "ubuntu": true, // Ubuntu + "debian": true, // Debian + "centos": true, // CentOS + "fedora": true, // Fedora + "alpine": true, // Alpine Linux + "arch": true, // Arch Linux + + // Cloud provider default users + "ec2-user": true, // AWS Amazon Linux + "azureuser": true, // Azure + "opc": true, // Oracle Cloud + "cloud-user": true, // Various cloud images + "cloud_user": true, // Alternative format + + // Container/orchestration platforms + "core": true, // CoreOS + "rancher": true, // RancherOS + "docker": true, // Docker + + // Other common defaults + "user": true, // Generic + "guest": true, // Guest account + "vagrant": true, // Vagrant boxes + } + + return commonUsers[strings.ToLower(user)] +} diff --git a/internal/adapters/ui/alias_utils_test.go b/internal/adapters/ui/alias_utils_test.go new file mode 100644 index 0000000..9c0a568 --- /dev/null +++ b/internal/adapters/ui/alias_utils_test.go @@ -0,0 +1,220 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import "testing" + +func TestGenerateUniqueAlias(t *testing.T) { + tests := []struct { + name string + baseAlias string + existingAliases []string + want string + }{ + { + name: "no duplicates", + baseAlias: "server", + existingAliases: []string{"other", "another"}, + want: "server", + }, + { + name: "one duplicate", + baseAlias: "server", + existingAliases: []string{"server", "other"}, + want: "server_1", + }, + { + name: "multiple duplicates in sequence", + baseAlias: "server", + existingAliases: []string{"server", "server_1", "server_2"}, + want: "server_3", + }, + { + name: "duplicates with gap", + baseAlias: "server", + existingAliases: []string{"server", "server_1", "server_5"}, + want: "server_6", + }, + { + name: "empty existing aliases", + baseAlias: "server", + existingAliases: []string{}, + want: "server", + }, + { + name: "nil existing aliases", + baseAlias: "server", + existingAliases: nil, + want: "server", + }, + { + name: "copying alias with suffix", + baseAlias: "123_1", + existingAliases: []string{"123", "123_1"}, + want: "123_2", + }, + { + name: "copying alias with suffix and gap", + baseAlias: "server_2", + existingAliases: []string{"server", "server_1", "server_2", "server_5"}, + want: "server_6", + }, + { + name: "alias with underscore but not numeric suffix", + baseAlias: "my_server", + existingAliases: []string{"my_server"}, + want: "my_server_1", + }, + { + name: "complex case with numeric alias", + baseAlias: "123", + existingAliases: []string{"123"}, + want: "123_1", + }, + { + name: "copy already suffixed numeric alias", + baseAlias: "123_1", + existingAliases: []string{"123", "123_1", "123_2"}, + want: "123_3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateUniqueAlias(tt.baseAlias, tt.existingAliases) + if got != tt.want { + t.Errorf("GenerateUniqueAlias() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGenerateSmartAlias(t *testing.T) { + tests := []struct { + name string + host string + user string + port int + want string + }{ + { + name: "simple domain", + host: "example.com", + user: "", + port: 22, + want: "example", + }, + { + name: "www prefix", + host: "www.example.com", + user: "", + port: 22, + want: "example", + }, + { + name: "subdomain", + host: "api.github.com", + user: "", + port: 22, + want: "api.github", + }, + { + name: "complex subdomain", + host: "dev.server.example.com", + user: "", + port: 22, + want: "dev.server", + }, + { + name: "IP address", + host: "192.168.1.1", + user: "", + port: 22, + want: "192.168.1.1", + }, + { + name: "with non-standard port", + host: "example.com", + user: "", + port: 2222, + want: "example:2222", + }, + { + name: "with custom user", + host: "example.com", + user: "developer", + port: 22, + want: "developer@example", + }, + { + name: "with common user (root)", + host: "example.com", + user: "root", + port: 22, + want: "example", + }, + { + name: "with common user (ubuntu)", + host: "example.com", + user: "ubuntu", + port: 22, + want: "example", + }, + { + name: "with common user (ec2-user)", + host: "example.com", + user: "ec2-user", + port: 22, + want: "example", + }, + { + name: "with common user (centos)", + host: "example.com", + user: "centos", + port: 22, + want: "example", + }, + { + name: "with common user (azureuser)", + host: "example.com", + user: "azureuser", + port: 22, + want: "example", + }, + { + name: "full combination", + host: "api.github.com", + user: "developer", + port: 2222, + want: "developer@api.github:2222", + }, + { + name: "IPv6 address", + host: "2001:db8::1", + user: "", + port: 22, + want: "2001:db8::1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateSmartAlias(tt.host, tt.user, tt.port) + if got != tt.want { + t.Errorf("GenerateSmartAlias() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/adapters/ui/utils_alias_test.go b/internal/adapters/ui/utils_alias_test.go new file mode 100644 index 0000000..a688b40 --- /dev/null +++ b/internal/adapters/ui/utils_alias_test.go @@ -0,0 +1,94 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "strings" + "testing" + + "github.com/Adembc/lazyssh/internal/core/domain" +) + +func TestBuildSSHCommand_AliasAndTags(t *testing.T) { + tests := []struct { + name string + server domain.Server + wantContains []string + wantPrefix string + }{ + { + name: "with alias only", + server: domain.Server{ + Alias: "myserver", + Host: "example.com", + User: "user", + }, + wantPrefix: "# lazyssh-alias:myserver", + wantContains: []string{ + "# lazyssh-alias:myserver", + "\nssh ", + "user@example.com", + }, + }, + { + name: "with alias and tags", + server: domain.Server{ + Alias: "prod-server", + Host: "prod.example.com", + User: "admin", + Tags: []string{"production", "critical", "web"}, + }, + wantPrefix: "# lazyssh-alias:prod-server tags:production,critical,web", + wantContains: []string{ + "# lazyssh-alias:prod-server tags:production,critical,web", + "\nssh ", + "admin@prod.example.com", + }, + }, + { + name: "with alias and single tag", + server: domain.Server{ + Alias: "dev-server", + Host: "dev.example.com", + User: "developer", + Tags: []string{"development"}, + }, + wantPrefix: "# lazyssh-alias:dev-server tags:development", + wantContains: []string{ + "# lazyssh-alias:dev-server tags:development", + "\nssh ", + "developer@dev.example.com", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := BuildSSHCommand(tt.server) + + // Check prefix + if !strings.HasPrefix(result, tt.wantPrefix) { + t.Errorf("BuildSSHCommand() prefix = %q, want prefix %q", result[:len(tt.wantPrefix)], tt.wantPrefix) + } + + // Check contains + for _, want := range tt.wantContains { + if !strings.Contains(result, want) { + t.Errorf("BuildSSHCommand() missing %q in result: %q", want, result) + } + } + }) + } +} From d20c5522c509ad99b14b6aa057698fe7f104002a Mon Sep 17 00:00:00 2001 From: Pei-Tang Huang Date: Fri, 19 Sep 2025 23:41:48 +0800 Subject: [PATCH 3/6] feat: add IP address validation and duplicate alias checking - Add IsIPAddress public function for shared IP validation - Add GetFieldValidatorsWithContext for context-aware validation - Implement duplicate alias validation with support for edit mode - Share IP validation logic between alias generation and validation - Add comprehensive test coverage for IPv4 and IPv6 addresses - Add tests for duplicate alias validation scenarios --- internal/adapters/ui/validation.go | 39 ++++++- internal/adapters/ui/validation_ip_test.go | 123 +++++++++++++++++++++ internal/adapters/ui/validation_test.go | 80 ++++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 internal/adapters/ui/validation_ip_test.go diff --git a/internal/adapters/ui/validation.go b/internal/adapters/ui/validation.go index 248c7d6..8d3bed9 100644 --- a/internal/adapters/ui/validation.go +++ b/internal/adapters/ui/validation.go @@ -131,12 +131,20 @@ const invalidAddressChars = "@#$%^&()=+{}|\\;:'\"<>,?/" // GetFieldValidators returns validation rules for SSH configuration fields func GetFieldValidators() map[string]fieldValidator { + return GetFieldValidatorsWithContext("", nil) +} + +// GetFieldValidatorsWithContext returns validation rules with context-aware validators +// originalAlias is used to exclude the current alias when editing +// existingAliases is used to check for duplicates +func GetFieldValidatorsWithContext(originalAlias string, existingAliases []string) map[string]fieldValidator { validators := make(map[string]fieldValidator) // Basic fields validators["Alias"] = fieldValidator{ Required: true, Pattern: regexp.MustCompile(`^[a-zA-Z0-9._-]+$`), + Validate: createAliasValidator(originalAlias, existingAliases), Message: "Alias is required and can only contain letters, numbers, dots, hyphens, and underscores", } validators["Host"] = fieldValidator{ @@ -447,6 +455,35 @@ func validateKnownHostsFiles(files string) error { return validateFilePaths(files, " ") } +// createAliasValidator creates a validator for alias uniqueness +func createAliasValidator(originalAlias string, existingAliases []string) func(string) error { + return func(alias string) error { + // Skip duplicate check if no existing aliases provided + if len(existingAliases) == 0 { + return nil + } + + // Skip check if alias hasn't changed (edit mode) + if originalAlias != "" && alias == originalAlias { + return nil + } + + // Check for duplicates + for _, existing := range existingAliases { + if existing == alias { + return fmt.Errorf("alias '%s' already exists", alias) + } + } + + return nil + } +} + +// IsIPAddress checks if the given string is a valid IP address (IPv4 or IPv6) +func IsIPAddress(host string) bool { + return net.ParseIP(host) != nil +} + // validateHost validates a hostname or IP address func validateHost(host string) error { if host == "" { @@ -459,7 +496,7 @@ func validateHost(host string) error { } // Try to parse as IP address first - if net.ParseIP(host) != nil { + if IsIPAddress(host) { return nil } diff --git a/internal/adapters/ui/validation_ip_test.go b/internal/adapters/ui/validation_ip_test.go new file mode 100644 index 0000000..2a9402b --- /dev/null +++ b/internal/adapters/ui/validation_ip_test.go @@ -0,0 +1,123 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import "testing" + +func TestIsIPAddress(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + // Valid IPv4 addresses + { + name: "valid IPv4", + input: "192.168.1.1", + want: true, + }, + { + name: "valid IPv4 - localhost", + input: "127.0.0.1", + want: true, + }, + { + name: "valid IPv4 - zeros", + input: "0.0.0.0", + want: true, + }, + { + name: "valid IPv4 - max", + input: "255.255.255.255", + want: true, + }, + // Valid IPv6 addresses + { + name: "valid IPv6 - full", + input: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + want: true, + }, + { + name: "valid IPv6 - compressed", + input: "2001:db8:85a3::8a2e:370:7334", + want: true, + }, + { + name: "valid IPv6 - localhost", + input: "::1", + want: true, + }, + { + name: "valid IPv6 - all zeros", + input: "::", + want: true, + }, + // Invalid addresses + { + name: "invalid - hostname", + input: "example.com", + want: false, + }, + { + name: "invalid - hostname with subdomain", + input: "api.example.com", + want: false, + }, + { + name: "invalid - IPv4 out of range", + input: "256.256.256.256", + want: false, + }, + { + name: "invalid - IPv4 with letters", + input: "192.168.1.a", + want: false, + }, + { + name: "invalid - too many octets", + input: "192.168.1.1.1", + want: false, + }, + { + name: "invalid - too few octets", + input: "192.168.1", + want: false, + }, + { + name: "invalid - empty string", + input: "", + want: false, + }, + { + name: "invalid - just dots", + input: "...", + want: false, + }, + { + name: "invalid - localhost name", + input: "localhost", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsIPAddress(tt.input) + if got != tt.want { + t.Errorf("IsIPAddress(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/adapters/ui/validation_test.go b/internal/adapters/ui/validation_test.go index de33639..13285ad 100644 --- a/internal/adapters/ui/validation_test.go +++ b/internal/adapters/ui/validation_test.go @@ -17,6 +17,7 @@ package ui import ( "os" "path/filepath" + "strings" "testing" ) @@ -327,3 +328,82 @@ func TestValidationState_Clear(t *testing.T) { t.Errorf("Expected error count to be 0, got %d", state.GetErrorCount()) } } + +func TestAliasValidation(t *testing.T) { + tests := []struct { + name string + alias string + originalAlias string + existingAliases []string + wantErr bool + errContains string + }{ + { + name: "valid new alias", + alias: "newserver", + originalAlias: "", + existingAliases: []string{"server1", "server2"}, + wantErr: false, + }, + { + name: "duplicate alias", + alias: "server1", + originalAlias: "", + existingAliases: []string{"server1", "server2"}, + wantErr: true, + errContains: "already exists", + }, + { + name: "edit mode - same alias allowed", + alias: "server1", + originalAlias: "server1", + existingAliases: []string{"server1", "server2"}, + wantErr: false, + }, + { + name: "edit mode - changed to duplicate", + alias: "server2", + originalAlias: "server1", + existingAliases: []string{"server1", "server2"}, + wantErr: true, + errContains: "already exists", + }, + { + name: "no existing aliases", + alias: "anyserver", + originalAlias: "", + existingAliases: nil, + wantErr: false, + }, + { + name: "empty existing aliases", + alias: "anyserver", + originalAlias: "", + existingAliases: []string{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + validators := GetFieldValidatorsWithContext(tt.originalAlias, tt.existingAliases) + aliasValidator := validators["Alias"] + + var err error + if aliasValidator.Validate != nil { + err = aliasValidator.Validate(tt.alias) + } + + if (err != nil) != tt.wantErr { + t.Errorf("alias validation error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err != nil && tt.errContains != "" { + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("error message = %v, want to contain %v", err.Error(), tt.errContains) + } + } + }) + } +} From 3af0c0c60da479adc0a967a7e8db1fded57cd97b Mon Sep 17 00:00:00 2001 From: Pei-Tang Huang Date: Fri, 19 Sep 2025 23:42:03 +0800 Subject: [PATCH 4/6] feat: add paste SSH command feature with 'v' keybinding - Add 'v' keybinding to paste and parse SSH commands from clipboard - Parse SSH command using the new SSH parser - Auto-generate unique alias if duplicate detected - Open server form in Add mode with parsed data - Update UI components to show 'v' keybinding: - Hint bar: Add 'v' to keybinding hints - Status bar: Add 'v' Paste SSH to navigation help - Server details: Add 'v' to commands list - Support for clipboard integration via github.com/atotto/clipboard - Add getExistingAliases helper to retrieve all current aliases --- README.md | 2 + internal/adapters/ui/handlers.go | 54 +++++++++++++++++++++++++- internal/adapters/ui/hint_bar.go | 2 +- internal/adapters/ui/server_details.go | 2 +- internal/adapters/ui/status_bar.go | 2 +- 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4b50d7f..d81eaf9 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw ### Quick Server Navigation - 🔍 Fuzzy search by alias, IP, or tags. - 🖥 One‑keypress SSH into the selected server (Enter). +- 📋 Copy SSH command to clipboard & paste SSH commands from clipboard to quickly add servers. - 🏷 Tag servers (e.g., prod, dev, test) for quick filtering. - ↕️ Sort by alias or last SSH (toggle + reverse). @@ -168,6 +169,7 @@ make run | ↑↓/jk | Navigate servers | | Enter | SSH into selected server | | c | Copy SSH command to clipboard | +| v | Paste SSH command from clipboard | | g | Ping selected server | | r | Refresh background data | | a | Add server | diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 7b1becd..b148187 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -63,6 +63,9 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey { case 'c': t.handleCopyCommand() return nil + case 'v': + t.handlePasteCommand() + return nil case 'g': t.handlePingSelected() return nil @@ -125,6 +128,37 @@ func (t *tui) handleCopyCommand() { } } +func (t *tui) handlePasteCommand() { + // Read from clipboard + clipContent, err := clipboard.ReadAll() + if err != nil { + t.showStatusTemp("Failed to read from clipboard") + return + } + + // Try to parse as SSH command + server, err := ParseSSHCommand(clipContent) + if err != nil { + t.showStatusTemp("Invalid SSH command in clipboard: " + err.Error()) + return + } + + // Check for duplicate alias and auto-adjust if necessary + existingAliases := t.getExistingAliases() + server.Alias = GenerateUniqueAlias(server.Alias, existingAliases) + + // Show the server form with parsed data + // Note: For Add mode, original should be nil. We'll set initial data separately. + form := NewServerForm(ServerFormAdd, nil). + SetInitialData(server). + SetApp(t.app). + SetVersionInfo(t.version, t.commit). + OnSave(t.handleServerSave). + OnCancel(t.handleFormCancel). + SetExistingAliases(t.getExistingAliases()) + t.app.SetRoot(form, true) +} + func (t *tui) handleTagsEdit() { if server, ok := t.serverList.GetSelectedServer(); ok { t.showEditTagsForm(server) @@ -186,7 +220,8 @@ func (t *tui) handleServerAdd() { SetApp(t.app). SetVersionInfo(t.version, t.commit). OnSave(t.handleServerSave). - OnCancel(t.handleFormCancel) + OnCancel(t.handleFormCancel). + SetExistingAliases(t.getExistingAliases()) t.app.SetRoot(form, true) } @@ -196,7 +231,8 @@ func (t *tui) handleServerEdit() { SetApp(t.app). SetVersionInfo(t.version, t.commit). OnSave(t.handleServerSave). - OnCancel(t.handleFormCancel) + OnCancel(t.handleFormCancel). + SetExistingAliases(t.getExistingAliases()) t.app.SetRoot(form, true) } } @@ -234,6 +270,20 @@ func (t *tui) handleFormCancel() { t.returnToMain() } +// getExistingAliases returns all existing server aliases +func (t *tui) getExistingAliases() []string { + servers, err := t.serverService.ListServers("") + if err != nil { + return []string{} + } + + aliases := make([]string, len(servers)) + for i, s := range servers { + aliases[i] = s.Alias + } + return aliases +} + func (t *tui) handlePingSelected() { if server, ok := t.serverList.GetSelectedServer(); ok { alias := server.Alias diff --git a/internal/adapters/ui/hint_bar.go b/internal/adapters/ui/hint_bar.go index de94973..e8f7d15 100644 --- a/internal/adapters/ui/hint_bar.go +++ b/internal/adapters/ui/hint_bar.go @@ -22,6 +22,6 @@ import ( func NewHintBar() *tview.TextView { hint := tview.NewTextView().SetDynamicColors(true) hint.SetBackgroundColor(tcell.Color233) - hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • g Ping • r Refresh • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort[-]") + hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • v Paste SSH • g Ping • r Refresh • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort[-]") return hint } diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index 8e0a634..6b3f07b 100644 --- a/internal/adapters/ui/server_details.go +++ b/internal/adapters/ui/server_details.go @@ -213,7 +213,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { } // Commands list - text += "\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin" + text += "\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n v: Paste SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin" sd.TextView.SetText(text) } diff --git a/internal/adapters/ui/status_bar.go b/internal/adapters/ui/status_bar.go index d8ca0aa..9c2356d 100644 --- a/internal/adapters/ui/status_bar.go +++ b/internal/adapters/ui/status_bar.go @@ -20,7 +20,7 @@ import ( ) func DefaultStatusText() string { - return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit" + return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]c[-] Copy SSH • [white]v[-] Paste SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit" } func NewStatusBar() *tview.TextView { From f3df3c4015a848ea127cbee82859a4c106597f0f Mon Sep 17 00:00:00 2001 From: Pei-Tang Huang Date: Fri, 19 Sep 2025 23:42:16 +0800 Subject: [PATCH 5/6] refactor: update server form to support initial data for Add mode - Add initialData field to ServerForm struct for pre-filling forms - Add SetInitialData method to set pre-fill data separately from original - Update getDefaultValues to differentiate between Edit mode (original) and Add mode (initialData) - Fix issue where pasted servers were incorrectly treated as updates - Ensure proper mode handling when creating new servers from paste - Set default port to 22 when port is 0 or unspecified --- internal/adapters/ui/server_form.go | 241 ++++++++++++++++------------ 1 file changed, 137 insertions(+), 104 deletions(-) diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index 286b47f..bb4c797 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -44,27 +44,29 @@ const ( ) type ServerForm struct { - *tview.Flex // The root container (includes header, form panel and hint bar) - header *AppHeader // The app header - formPanel *tview.Flex // The actual form panel - pages *tview.Pages - tabBar *tview.TextView - forms map[string]*tview.Form - currentTab string - tabs []string - tabAbbrev map[string]string // Abbreviated tab names for narrow views - mode ServerFormMode - original *domain.Server - onSave func(domain.Server, *domain.Server) - onCancel func() - app *tview.Application // Reference to app for showing modals - version string // Version for header - commit string // Commit for header - validation *ValidationState // Validation state for all fields - helpPanel *tview.TextView // Help panel for field descriptions - helpMode HelpDisplayMode // Current help display mode - currentField string // Currently focused field - mainContainer *tview.Flex // Container for form and help panel + *tview.Flex // The root container (includes header, form panel and hint bar) + header *AppHeader // The app header + formPanel *tview.Flex // The actual form panel + pages *tview.Pages + tabBar *tview.TextView + forms map[string]*tview.Form + currentTab string + tabs []string + tabAbbrev map[string]string // Abbreviated tab names for narrow views + mode ServerFormMode + original *domain.Server + initialData *domain.Server // Initial data for pre-filling form (used in Add mode) + existingAliases []string // List of existing aliases for validation + onSave func(domain.Server, *domain.Server) + onCancel func() + app *tview.Application // Reference to app for showing modals + version string // Version for header + commit string // Commit for header + validation *ValidationState // Validation state for all fields + helpPanel *tview.TextView // Help panel for field descriptions + helpMode HelpDisplayMode // Current help display mode + currentField string // Currently focused field + mainContainer *tview.Flex // Container for form and help panel } func NewServerForm(mode ServerFormMode, original *domain.Server) *ServerForm { @@ -906,7 +908,13 @@ func (sf *ServerForm) createAlgorithmAutocomplete(suggestions []string) func(str // validateField validates a single field and updates the validation state func (sf *ServerForm) validateField(fieldName, value string) string { - fieldValidators := GetFieldValidators() + // Get validators with context for alias checking + originalAlias := "" + if sf.original != nil { + originalAlias = sf.original.Alias + } + fieldValidators := GetFieldValidatorsWithContext(originalAlias, sf.existingAliases) + validator, exists := fieldValidators[fieldName] if !exists { // No validator for this field, it's valid @@ -1064,78 +1072,92 @@ func (sf *ServerForm) validateAllFields() bool { // getDefaultValues returns default form values based on mode func (sf *ServerForm) getDefaultValues() ServerFormData { + // Determine which server data to use for defaults + var server *domain.Server if sf.mode == ServerFormEdit && sf.original != nil { + server = sf.original + } else if sf.mode == ServerFormAdd && sf.initialData != nil { + server = sf.initialData + } + + // Use server values if available + if server != nil { return ServerFormData{ - Alias: sf.original.Alias, - Host: sf.original.Host, - User: sf.original.User, - Port: fmt.Sprint(sf.original.Port), - Key: strings.Join(sf.original.IdentityFiles, ", "), - Tags: strings.Join(sf.original.Tags, ", "), - ProxyJump: sf.original.ProxyJump, - ProxyCommand: sf.original.ProxyCommand, - RemoteCommand: sf.original.RemoteCommand, - RequestTTY: sf.original.RequestTTY, - SessionType: sf.original.SessionType, - ConnectTimeout: sf.original.ConnectTimeout, - ConnectionAttempts: sf.original.ConnectionAttempts, - BindAddress: sf.original.BindAddress, - BindInterface: sf.original.BindInterface, - AddressFamily: sf.original.AddressFamily, - ExitOnForwardFailure: sf.original.ExitOnForwardFailure, - IPQoS: sf.original.IPQoS, + Alias: server.Alias, + Host: server.Host, + User: server.User, + Port: func() string { + if server.Port == 0 { + return "22" // Default SSH port + } + return fmt.Sprint(server.Port) + }(), + Key: strings.Join(server.IdentityFiles, ", "), + Tags: strings.Join(server.Tags, ", "), + ProxyJump: server.ProxyJump, + ProxyCommand: server.ProxyCommand, + RemoteCommand: server.RemoteCommand, + RequestTTY: server.RequestTTY, + SessionType: server.SessionType, + ConnectTimeout: server.ConnectTimeout, + ConnectionAttempts: server.ConnectionAttempts, + BindAddress: server.BindAddress, + BindInterface: server.BindInterface, + AddressFamily: server.AddressFamily, + ExitOnForwardFailure: server.ExitOnForwardFailure, + IPQoS: server.IPQoS, // Hostname canonicalization - CanonicalizeHostname: sf.original.CanonicalizeHostname, - CanonicalDomains: sf.original.CanonicalDomains, - CanonicalizeFallbackLocal: sf.original.CanonicalizeFallbackLocal, - CanonicalizeMaxDots: sf.original.CanonicalizeMaxDots, - CanonicalizePermittedCNAMEs: sf.original.CanonicalizePermittedCNAMEs, - GatewayPorts: sf.original.GatewayPorts, - LocalForward: strings.Join(sf.original.LocalForward, ", "), - RemoteForward: strings.Join(sf.original.RemoteForward, ", "), - DynamicForward: strings.Join(sf.original.DynamicForward, ", "), - ClearAllForwardings: sf.original.ClearAllForwardings, + CanonicalizeHostname: server.CanonicalizeHostname, + CanonicalDomains: server.CanonicalDomains, + CanonicalizeFallbackLocal: server.CanonicalizeFallbackLocal, + CanonicalizeMaxDots: server.CanonicalizeMaxDots, + CanonicalizePermittedCNAMEs: server.CanonicalizePermittedCNAMEs, + GatewayPorts: server.GatewayPorts, + LocalForward: strings.Join(server.LocalForward, ", "), + RemoteForward: strings.Join(server.RemoteForward, ", "), + DynamicForward: strings.Join(server.DynamicForward, ", "), + ClearAllForwardings: server.ClearAllForwardings, // Public key - PubkeyAuthentication: sf.original.PubkeyAuthentication, - IdentitiesOnly: sf.original.IdentitiesOnly, + PubkeyAuthentication: server.PubkeyAuthentication, + IdentitiesOnly: server.IdentitiesOnly, // SSH Agent - AddKeysToAgent: sf.original.AddKeysToAgent, - IdentityAgent: sf.original.IdentityAgent, + AddKeysToAgent: server.AddKeysToAgent, + IdentityAgent: server.IdentityAgent, // Password & Interactive - PasswordAuthentication: sf.original.PasswordAuthentication, - KbdInteractiveAuthentication: sf.original.KbdInteractiveAuthentication, - NumberOfPasswordPrompts: sf.original.NumberOfPasswordPrompts, + PasswordAuthentication: server.PasswordAuthentication, + KbdInteractiveAuthentication: server.KbdInteractiveAuthentication, + NumberOfPasswordPrompts: server.NumberOfPasswordPrompts, // Advanced - PreferredAuthentications: sf.original.PreferredAuthentications, - ForwardAgent: sf.original.ForwardAgent, - ForwardX11: sf.original.ForwardX11, - ForwardX11Trusted: sf.original.ForwardX11Trusted, - ControlMaster: sf.original.ControlMaster, - ControlPath: sf.original.ControlPath, - ControlPersist: sf.original.ControlPersist, - ServerAliveInterval: sf.original.ServerAliveInterval, - ServerAliveCountMax: sf.original.ServerAliveCountMax, - Compression: sf.original.Compression, - TCPKeepAlive: sf.original.TCPKeepAlive, - BatchMode: sf.original.BatchMode, - StrictHostKeyChecking: sf.original.StrictHostKeyChecking, - UserKnownHostsFile: sf.original.UserKnownHostsFile, - HostKeyAlgorithms: sf.original.HostKeyAlgorithms, - PubkeyAcceptedAlgorithms: sf.original.PubkeyAcceptedAlgorithms, - HostbasedAcceptedAlgorithms: sf.original.HostbasedAcceptedAlgorithms, - MACs: sf.original.MACs, - Ciphers: sf.original.Ciphers, - KexAlgorithms: sf.original.KexAlgorithms, - VerifyHostKeyDNS: sf.original.VerifyHostKeyDNS, - UpdateHostKeys: sf.original.UpdateHostKeys, - HashKnownHosts: sf.original.HashKnownHosts, - VisualHostKey: sf.original.VisualHostKey, - LocalCommand: sf.original.LocalCommand, - PermitLocalCommand: sf.original.PermitLocalCommand, - EscapeChar: sf.original.EscapeChar, - SendEnv: strings.Join(sf.original.SendEnv, ", "), - SetEnv: strings.Join(sf.original.SetEnv, ", "), - LogLevel: sf.original.LogLevel, + PreferredAuthentications: server.PreferredAuthentications, + ForwardAgent: server.ForwardAgent, + ForwardX11: server.ForwardX11, + ForwardX11Trusted: server.ForwardX11Trusted, + ControlMaster: server.ControlMaster, + ControlPath: server.ControlPath, + ControlPersist: server.ControlPersist, + ServerAliveInterval: server.ServerAliveInterval, + ServerAliveCountMax: server.ServerAliveCountMax, + Compression: server.Compression, + TCPKeepAlive: server.TCPKeepAlive, + BatchMode: server.BatchMode, + StrictHostKeyChecking: server.StrictHostKeyChecking, + UserKnownHostsFile: server.UserKnownHostsFile, + HostKeyAlgorithms: server.HostKeyAlgorithms, + PubkeyAcceptedAlgorithms: server.PubkeyAcceptedAlgorithms, + HostbasedAcceptedAlgorithms: server.HostbasedAcceptedAlgorithms, + MACs: server.MACs, + Ciphers: server.Ciphers, + KexAlgorithms: server.KexAlgorithms, + VerifyHostKeyDNS: server.VerifyHostKeyDNS, + UpdateHostKeys: server.UpdateHostKeys, + HashKnownHosts: server.HashKnownHosts, + VisualHostKey: server.VisualHostKey, + LocalCommand: server.LocalCommand, + PermitLocalCommand: server.PermitLocalCommand, + EscapeChar: server.EscapeChar, + SendEnv: strings.Join(server.SendEnv, ", "), + SetEnv: strings.Join(server.SetEnv, ", "), + LogLevel: server.LogLevel, } } // For new servers, use empty values instead of SSH defaults @@ -2008,28 +2030,28 @@ func (sf *ServerForm) handleCancel() { // hasUnsavedChanges checks if current form data differs from original func (sf *ServerForm) hasUnsavedChanges() bool { - // If creating new server, any non-empty required fields mean changes - if sf.mode == ServerFormAdd { - data := sf.getFormData() - return data.Alias != "" || data.Host != "" || data.User != "" - } + // If we have original data to compare against (Edit mode or Add with paste) + if sf.original != nil { + currentData := sf.getFormData() + currentServer := sf.dataToServer(currentData) - // If editing, compare with original - if sf.original == nil { - return false - } + // Use DeepEqual for simple comparison first + if reflect.DeepEqual(currentServer, *sf.original) { + return false + } - currentData := sf.getFormData() - currentServer := sf.dataToServer(currentData) + // If DeepEqual says they're different, use our custom comparison + // that handles nil vs empty slice and other normalization + return sf.serversDiffer(currentServer, *sf.original) + } - // Use DeepEqual for simple comparison first - if reflect.DeepEqual(currentServer, *sf.original) { - return false + // For new servers without original (regular Add), check if any data has been entered + if sf.mode == ServerFormAdd { + data := sf.getFormData() + return data.Alias != "" || data.Host != "" || data.User != "" } - // If DeepEqual says they're different, use our custom comparison - // that handles nil vs empty slice and other normalization - return sf.serversDiffer(currentServer, *sf.original) + return false } // serversDiffer compares two servers for differences using reflection @@ -2273,6 +2295,17 @@ func (sf *ServerForm) SetApp(app *tview.Application) *ServerForm { return sf } +func (sf *ServerForm) SetExistingAliases(aliases []string) *ServerForm { + sf.existingAliases = aliases + return sf +} + +// SetInitialData sets initial data for pre-filling the form in Add mode +func (sf *ServerForm) SetInitialData(server *domain.Server) *ServerForm { + sf.initialData = server + return sf +} + func (sf *ServerForm) SetVersionInfo(version, commit string) *ServerForm { sf.version = version sf.commit = commit From 8c7c1cef437986ca2b8e6dd8dc436bcaf306f480 Mon Sep 17 00:00:00 2001 From: Pei-Tang Huang Date: Fri, 19 Sep 2025 23:42:27 +0800 Subject: [PATCH 6/6] feat: enhance copy SSH command with alias and tags metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update BuildSSHCommand to include alias and tags as comment - Format: # lazyssh-alias: tags: - Add test coverage for BuildSSHCommand with tags - Enable round-trip capability (copy → paste preserves metadata) --- internal/adapters/ui/utils.go | 7 ++++++- internal/adapters/ui/utils_test.go | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/adapters/ui/utils.go b/internal/adapters/ui/utils.go index 95a3996..a97fbe2 100644 --- a/internal/adapters/ui/utils.go +++ b/internal/adapters/ui/utils.go @@ -123,7 +123,12 @@ func humanizeDuration(t time.Time) string { // BuildSSHCommand constructs a ready-to-run ssh command for the given server. // Format: ssh [options] [user@]host [command] func BuildSSHCommand(s domain.Server) string { - parts := []string{"ssh"} + // Start with alias and tags comment for easy parsing when pasting + comment := "# lazyssh-alias:" + s.Alias + if len(s.Tags) > 0 { + comment += " tags:" + strings.Join(s.Tags, ",") + } + parts := []string{comment, "\nssh"} // Add proxy and connection options addProxyOptions(&parts, s) diff --git a/internal/adapters/ui/utils_test.go b/internal/adapters/ui/utils_test.go index aad5ad8..988037c 100644 --- a/internal/adapters/ui/utils_test.go +++ b/internal/adapters/ui/utils_test.go @@ -115,9 +115,10 @@ func TestBuildSSHCommand_PortForwarding(t *testing.T) { } } - // Additional check: ensure the command starts with "ssh" - if !strings.HasPrefix(result, "ssh ") { - t.Errorf("BuildSSHCommand() should start with 'ssh ', got: %q", result) + // Additional check: ensure the command contains "ssh" command + // Now it includes alias comment, so check for "\nssh " or just "ssh " at the beginning + if !strings.Contains(result, "\nssh ") && !strings.HasPrefix(result, "ssh ") { + t.Errorf("BuildSSHCommand() should contain 'ssh ' command, got: %q", result) } }) } @@ -129,6 +130,7 @@ func TestBuildSSHCommand_CompleteCommand(t *testing.T) { Host: "example.com", User: "admin", Port: 2222, + Tags: []string{"production", "web"}, LocalForward: []string{"8080:localhost:80", "3306:db.internal:3306"}, RemoteForward: []string{"9090:localhost:9090"}, DynamicForward: []string{"1080"}, @@ -138,8 +140,14 @@ func TestBuildSSHCommand_CompleteCommand(t *testing.T) { result := BuildSSHCommand(server) // Check command structure - if !strings.HasPrefix(result, "ssh ") { - t.Errorf("Command should start with 'ssh ', got: %q", result) + // Check for alias and tags comment + if !strings.HasPrefix(result, "# lazyssh-alias:myserver tags:production,web") { + t.Errorf("Command should start with alias and tags comment, got: %q", result) + } + + // Check command contains ssh (now with alias comment) + if !strings.Contains(result, "\nssh ") { + t.Errorf("Command should contain 'ssh ' after alias comment, got: %q", result) } // Check port