diff --git a/internal/config/backup.go b/internal/config/backup.go index 3b6e175..74aa375 100644 --- a/internal/config/backup.go +++ b/internal/config/backup.go @@ -30,7 +30,7 @@ func (m *Manager) EnsureFirstRunBackup() error { metaPath := filepath.Join(backupDir, "metadata.json") if _, err := os.Stat(metaPath); err == nil { - return nil // backup already exists + return nil } var existingFiles []string @@ -43,7 +43,7 @@ func (m *Manager) EnsureFirstRunBackup() error { } } - // enforce secure, user-only read/write access to the backup folder + // why: openssh configuration backups must remain user-accessible only for security if err := os.MkdirAll(backupDir, 0700); err != nil { return fmt.Errorf("failed to create backup directory: %w", err) } @@ -73,7 +73,7 @@ func (m *Manager) EnsureFirstRunBackup() error { return fmt.Errorf("failed to marshal backup metadata: %w", err) } - // enforce secure user-only access on the metadata file + // why: metadata file stores sensitive backup paths and must be restricted if err := os.WriteFile(metaPath, metaBytes, 0600); err != nil { return fmt.Errorf("failed to write backup metadata: %w", err) } diff --git a/internal/config/backup_test.go b/internal/config/backup_test.go index 38fdb1a..2776618 100644 --- a/internal/config/backup_test.go +++ b/internal/config/backup_test.go @@ -31,7 +31,7 @@ func TestEnsureFirstRunBackup(t *testing.T) { backupDir := filepath.Join(tmpDir, "pre-tusshi") assert.DirExists(t, backupDir) - // check folder permissions (must be 0700 on Unix) + // check folder permissions (must be 0700 on unix) info, err := os.Stat(backupDir) assert.NoError(t, err) assert.Equal(t, os.ModeDir|0700, info.Mode().Perm()|os.ModeDir) diff --git a/internal/config/config_file.go b/internal/config/config_file.go index 04fa954..0e1adf9 100644 --- a/internal/config/config_file.go +++ b/internal/config/config_file.go @@ -28,7 +28,6 @@ func (m *Manager) AddConfigFile(targetPath string) error { return err } - // write a simple marker comment to represent a blank config file if err := os.WriteFile(absTarget, []byte("# SSH config file created by tusshi\n"), 0600); err != nil { return err } diff --git a/internal/config/editor.go b/internal/config/editor.go index f48e771..26e861c 100644 --- a/internal/config/editor.go +++ b/internal/config/editor.go @@ -113,8 +113,7 @@ func (m *Manager) UpdateHost(originalAlias string, h *Host) error { } if newASTHost != nil { - // Keep any existing comment nodes from the original host block - // This makes sure we don't remove user generated comments from the file + // why: user comments must be preserved inside the edited host block to prevent data loss for _, node := range targetCfg.Hosts[targetIdx].Nodes { if _, isComment := node.(*ssh_config.Empty); isComment { newASTHost.Nodes = append(newASTHost.Nodes, node) @@ -223,13 +222,11 @@ func (m *Manager) registerInclude(includePath string) error { } } - // Decode the include line using parser to create the correct node type cleanly decoded, err := ssh_config.Decode(strings.NewReader("Include " + relPath + "\n")) if err != nil || len(decoded.Hosts) == 0 { return fmt.Errorf("failed to generate include AST node: %w", err) } - // Insert Include block at the very top of the primary config's Host list primaryCfg.Hosts = append([]*ssh_config.Host{decoded.Hosts[0]}, primaryCfg.Hosts...) return m.SaveFile(m.PrimaryPath) } diff --git a/internal/config/manager.go b/internal/config/manager.go index 9cf8384..74737dc 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "maps" "os" "path/filepath" "reflect" @@ -50,7 +51,6 @@ func (m *Manager) Load() error { // It automatically resolves wildcard settings for each specific host. func (m *Manager) GetHosts() []*Host { var hosts []*Host - // We gather global wildcard configs to perform inheritance resolution later. globalConfig := m.buildGlobalConfig() for _, filePath := range m.FileOrder { @@ -60,7 +60,7 @@ func (m *Manager) GetHosts() []*Host { } for _, astHost := range cfg.Hosts { - // Skip the implicit default "Host *" block added by the parser + // why: the parser injects a default implicit host block that we do not want to display val := reflect.ValueOf(astHost) if val.Kind() == reflect.Pointer && !val.IsNil() { elem := val.Elem() @@ -70,7 +70,6 @@ func (m *Manager) GetHosts() []*Host { } } - // Extract all aliases defined in this Host block. for _, pat := range astHost.Patterns { alias := pat.String() if alias == "" { @@ -85,26 +84,20 @@ func (m *Manager) GetHosts() []*Host { Properties: make(map[string]string), } - // Extract explicit key-value properties from the host block's nodes. for _, node := range astHost.Nodes { if kv, ok := node.(*ssh_config.KV); ok { h.Properties[kv.Key] = kv.Value } } - // Map critical properties to top-level fields for convenience. h.Name = h.Properties["HostName"] h.User = h.Properties["User"] h.Port = h.Properties["Port"] h.IdentityFile = h.Properties["IdentityFile"] - // Resolve final values using global OpenSSH inheritance. h.ResolvedProperties = make(map[string]string) - for k, v := range h.Properties { - h.ResolvedProperties[k] = v - } + maps.Copy(h.ResolvedProperties, h.Properties) - // Inject inherited properties from matching wildcard blocks. if !isWildcard && globalConfig != nil { for _, key := range []string{keyHostName, keyUser, keyPort, keyIdentityFile, keyForwardAgent, keyProxyJump} { if _, explicit := h.Properties[key]; !explicit { @@ -115,7 +108,6 @@ func (m *Manager) GetHosts() []*Host { } } - // Update resolved shortcuts. if h.Name == "" && h.ResolvedProperties[keyHostName] != "" { h.Name = h.ResolvedProperties[keyHostName] } @@ -150,7 +142,7 @@ func (m *Manager) loadPath(path string, depth int) error { f, err := os.Open(filepath.Clean(path)) if err != nil { if os.IsNotExist(err) && path == m.PrimaryPath { - // If primary file doesn't exist, we start with a clean empty config + // why: if primary file doesn't exist, we start with a clean empty config m.Configs[path] = &ssh_config.Config{Hosts: []*ssh_config.Host{}} return nil } @@ -167,7 +159,6 @@ func (m *Manager) loadPath(path string, depth int) error { m.Configs[path] = cfg - // Scan AST nodes to discover and parse any Include directives. for _, astHost := range cfg.Hosts { for _, node := range astHost.Nodes { if incl, ok := node.(*ssh_config.Include); ok { @@ -208,7 +199,6 @@ func (m *Manager) resolveAndLoadIncludes(pattern string, depth int) { } if err := m.loadPath(absMatch, depth); err == nil { - // Track order of newly discovered files. found := slices.Contains(m.FileOrder, absMatch) if !found { m.FileOrder = append(m.FileOrder, absMatch) @@ -238,3 +228,14 @@ func expandTilde(path string) string { } return path } + +// FindConfigFile searches FileOrder for a path matching the given name, +// base name, or extensionless nickname. +func (m *Manager) FindConfigFile(name string) (string, bool) { + for _, file := range m.FileOrder { + if file == name || filepath.Base(file) == name || strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) == name { + return file, true + } + } + return "", false +} diff --git a/internal/config/manager_test.go b/internal/config/manager_test.go index 5eef109..99625db 100644 --- a/internal/config/manager_test.go +++ b/internal/config/manager_test.go @@ -37,7 +37,7 @@ Host 10.200.1.46 assert.NoError(t, err) hosts := mgr.GetHosts() - // Total hosts: Host *, prod-web-01, 10.200.1.46 + // total hosts: Host *, prod-web-01, 10.200.1.46 assert.Len(t, hosts, 3) var wildcardHost, prodHost, dbHost *Host @@ -56,17 +56,17 @@ Host 10.200.1.46 assert.True(t, wildcardHost.IsWildcard) assert.Equal(t, "default_user", wildcardHost.User) - // Verify prodHost details and explicit key mapping + // verify prodHost details and explicit key mapping assert.NotNil(t, prodHost) assert.False(t, prodHost.IsWildcard) assert.Equal(t, "10.200.1.45", prodHost.Name) assert.Equal(t, "deploy", prodHost.User) assert.Equal(t, "~/.ssh/keys/work_rsa", prodHost.IdentityFile) - // Verify prodHost inherited port 2222 from wildcard Host * + // verify prodHost inherited port 2222 from wildcard Host * assert.Equal(t, "2222", prodHost.ResolvedProperties["Port"]) assert.Equal(t, "yes", prodHost.ResolvedProperties[keyForwardAgent]) - // Verify dbHost does not have alias (its alias is the IP itself) + // verify dbHost does not have alias (its alias is the IP itself) assert.NotNil(t, dbHost) assert.Equal(t, "10.200.1.46", dbHost.Alias) // dbHost should inherit User, Port, and ForwardAgent from wildcard @@ -136,7 +136,7 @@ Host my-host err = mgr.AddHost(primaryPath, newHost) assert.NoError(t, err) - // Reload to verify write + // reload to verify write mgr2 := NewManager(primaryPath) err = mgr2.Load() assert.NoError(t, err) @@ -169,7 +169,7 @@ Host my-host err = mgr2.UpdateHost("added-host", updatedHost) assert.NoError(t, err) - // Reload to verify update + // reload to verify update mgr3 := NewManager(primaryPath) err = mgr3.Load() assert.NoError(t, err) @@ -193,7 +193,7 @@ Host my-host err = mgr3.DeleteHost("added-host-new") assert.NoError(t, err) - // Reload to verify delete + // reload to verify delete mgr4 := NewManager(primaryPath) err = mgr4.Load() assert.NoError(t, err) @@ -213,13 +213,13 @@ func TestManagerConfigFileCRUD(t *testing.T) { subPath := filepath.Join(tmpDir, "sub-config") - // 1. Add Config File + // 1. add config file err = mgr.AddConfigFile(subPath) assert.NoError(t, err) assert.FileExists(t, subPath) assert.Contains(t, mgr.FileOrder, subPath) - // Check if Include directive is added in primary + // check if Include directive is added in primary // #nosec G304 primaryContent, err := os.ReadFile(primaryPath) assert.NoError(t, err) @@ -227,7 +227,7 @@ func TestManagerConfigFileCRUD(t *testing.T) { assert.NoError(t, err) assert.Contains(t, string(primaryContent), "Include "+relSub) - // 2. Rename Config File + // 2. rename config file renamedPath := filepath.Join(tmpDir, "renamed-config") err = mgr.RenameConfigFile(subPath, renamedPath) assert.NoError(t, err) @@ -236,7 +236,7 @@ func TestManagerConfigFileCRUD(t *testing.T) { assert.Contains(t, mgr.FileOrder, renamedPath) assert.NotContains(t, mgr.FileOrder, subPath) - // Check if Include is updated in primary + // check if Include is updated in primary // #nosec G304 primaryContent2, err := os.ReadFile(primaryPath) assert.NoError(t, err) @@ -245,7 +245,7 @@ func TestManagerConfigFileCRUD(t *testing.T) { assert.Contains(t, string(primaryContent2), "Include "+relRenamed) assert.NotContains(t, string(primaryContent2), "Include "+relSub) - // 3. Prevent deleting if connections are present + // 3. prevent deleting if connections are present h := &Host{ Alias: "test-host", Name: "127.0.0.1", @@ -257,17 +257,17 @@ func TestManagerConfigFileCRUD(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "connections are still present") - // Delete host first + // delete host first err = mgr.DeleteHost("test-host") assert.NoError(t, err) - // 4. Delete Config File successfully when no connections are present + // 4. delete config file successfully when no connections are present err = mgr.DeleteConfigFile(renamedPath) assert.NoError(t, err) assert.NoFileExists(t, renamedPath) assert.NotContains(t, mgr.FileOrder, renamedPath) - // Check if Include is removed from primary + // check if Include is removed from primary // #nosec G304 primaryContent3, err := os.ReadFile(primaryPath) assert.NoError(t, err) diff --git a/internal/tui/commands/config.go b/internal/tui/commands/config.go index c7d9590..311e805 100644 --- a/internal/tui/commands/config.go +++ b/internal/tui/commands/config.go @@ -69,15 +69,8 @@ func RenameConfig(mgr *config.Manager, parts []string) func(Context) { return } - var oldPath string - for _, file := range mgr.FileOrder { - if file == oldName || filepath.Base(file) == oldName || strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) == oldName { - oldPath = file - break - } - } - - if oldPath == "" { + oldPath, found := mgr.FindConfigFile(oldName) + if !found { ctx.SetError(fmt.Sprintf("Config file %q not found", oldName)) return } @@ -122,15 +115,8 @@ func DeleteConfig(mgr *config.Manager, parts []string) func(Context) { targetName = activeTab } - var targetPath string - for _, file := range mgr.FileOrder { - if file == targetName || filepath.Base(file) == targetName || strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) == targetName { - targetPath = file - break - } - } - - if targetPath == "" { + targetPath, found := mgr.FindConfigFile(targetName) + if !found { ctx.SetError(fmt.Sprintf("Config file %q not found", targetName)) return } diff --git a/internal/tui/commands/move.go b/internal/tui/commands/move.go index ad50105..a9d04a0 100644 --- a/internal/tui/commands/move.go +++ b/internal/tui/commands/move.go @@ -3,7 +3,6 @@ package commands import ( "fmt" "path/filepath" - "strings" "tusshi/internal/config" ) @@ -17,15 +16,8 @@ func Move(mgr *config.Manager, selectedHost *config.Host, parts []string) func(C } targetNickname := parts[1] - var matchedFile string - for _, file := range mgr.FileOrder { - if filepath.Base(file) == targetNickname || strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) == targetNickname { - matchedFile = file - break - } - } - - if matchedFile == "" { + matchedFile, found := mgr.FindConfigFile(targetNickname) + if !found { matchedFile = filepath.Join(filepath.Dir(mgr.PrimaryPath), targetNickname) } diff --git a/internal/tui/forms.go b/internal/tui/forms.go index f146d84..6583c34 100644 --- a/internal/tui/forms.go +++ b/internal/tui/forms.go @@ -31,23 +31,19 @@ func (m *Model) BuildHostForm(defaultFile string) *huh.Form { m.FormHost.IdentityFile = selected.IdentityFile m.FormDestFile = selected.SourceFile - // Pre-populate advanced fields m.FormProxyJump = selected.Properties["ProxyJump"] if agent, ok := selected.Properties["ForwardAgent"]; ok { m.FormForwardAgent = agent } } - // Build destination options for file selector var fileOptions []huh.Option[string] for _, f := range m.Manager.FileOrder { fileOptions = append(fileOptions, huh.NewOption(filepath.Base(f), f)) } - // Create dynamic steps var groups []*huh.Group - // Step 1: If creating and "All" tab is active, show file selection if m.FormAction == actionAdd && (defaultFile == tabAll || defaultFile == "") { groups = append(groups, huh.NewGroup( huh.NewSelect[string](). @@ -58,7 +54,6 @@ func (m *Model) BuildHostForm(defaultFile string) *huh.Form { )) } - // Step 2: Core Connection Properties groups = append(groups, huh.NewGroup( huh.NewInput(). Title("Alias / Connection Name"). @@ -83,7 +78,6 @@ func (m *Model) BuildHostForm(defaultFile string) *huh.Form { Value(&m.FormHost.Port), )) - // Step 3: Advanced Options (IdentityFile, ProxyJump, ForwardAgent) groups = append(groups, huh.NewGroup( huh.NewInput(). Title("Identity File Path"). @@ -105,7 +99,6 @@ func (m *Model) BuildHostForm(defaultFile string) *huh.Form { Value(&m.FormForwardAgent), )) - // Construct the final beautiful form form := huh.NewForm(groups...). WithTheme(huh.ThemeCharm()). WithWidth(60). diff --git a/internal/tui/model.go b/internal/tui/model.go index 9471afe..3713ac5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -110,7 +110,6 @@ func (m *Model) Reload() { } m.Hosts = m.Manager.GetHosts() - // prune ping results for hosts that no longer exist if m.PingResults != nil { activeAliases := make(map[string]bool) for _, h := range m.Hosts { @@ -125,7 +124,7 @@ func (m *Model) Reload() { m.Tabs = []string{tabAll} for _, f := range m.Manager.FileOrder { - // Filter out the "config" file as it can't be renamed or deleted and just creates confusion. + // why: primary ssh config must not be renamed or deleted via the UI if f == m.Manager.PrimaryPath { hasConnections := false for _, h := range m.Hosts { @@ -139,11 +138,9 @@ func (m *Model) Reload() { } continue } - // All other config files are added as tabs m.Tabs = append(m.Tabs, f) } - // Default active tab to All if not set or invalid tabValid := false for _, t := range m.Tabs { if t == m.ActiveTab { @@ -164,17 +161,15 @@ func (m *Model) FilterHosts() { searchQ := strings.ToLower(m.SearchInput.Value()) for _, h := range m.Hosts { - // Tab matching if m.ActiveTab != "All" && h.SourceFile != m.ActiveTab { continue } - // Skip wildcards from the main listing to keep it connection-focused + // why: wildcard configs (e.g. Host *) are metadata, not connectable hosts if h.IsWildcard { continue } - // Substring match on Alias, Address (Name), or User aliasMatch := strings.Contains(strings.ToLower(h.Alias), searchQ) nameMatch := strings.Contains(strings.ToLower(h.Name), searchQ) userMatch := strings.Contains(strings.ToLower(h.User), searchQ) @@ -186,7 +181,6 @@ func (m *Model) FilterHosts() { m.Filtered = filtered - // Clamp selected index within range if m.SelectedIndex >= len(m.Filtered) { m.SelectedIndex = len(m.Filtered) - 1 } diff --git a/internal/tui/ping.go b/internal/tui/ping.go index dd00104..6f30446 100644 --- a/internal/tui/ping.go +++ b/internal/tui/ping.go @@ -26,7 +26,7 @@ type PingResultMsg struct { Latency float64 } -// Limit concurrency to 15 to avoid fd exhaustion or firewall bans. +// why: limit concurrency to 15 to avoid fd exhaustion or firewall bans var pingSemaphore = make(chan struct{}, 15) // PingHost performs a non-blocking TCP dial check against the host. diff --git a/internal/tui/update.go b/internal/tui/update.go index 1c93895..436394a 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -22,7 +22,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case SSHFinishedMsg: - // SSH terminated; clear screen and restore terminal if msg.Err != nil { m.ErrorText = fmt.Sprintf("SSH session error: %v", msg.Err) } else { @@ -46,12 +45,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyCtrlC { return m, tea.Quit } - // Reset temporary notifications on keypress m.AlertText = "" m.ErrorText = "" } - // Delegate to active overlay component if one is open if m.ActiveComponent != nil { var activeCmd tea.Cmd activeCmd, done := m.ActiveComponent.Update(msg) @@ -62,12 +59,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, activeCmd } - // Delegate based on active mode if msg, ok := msg.(tea.KeyMsg); ok { return m.handleKeyMsg(msg) } - // Forward non-key messages (such as blink timers) to active text inputs switch m.Mode { case ModeSearch: var searchCmd tea.Cmd @@ -106,7 +101,6 @@ func (m *Model) navigateTabs(direction int) { func (m *Model) executeFormSubmit() { var err error - // Map advanced properties to the FormHost structure m.FormHost.SourceFile = m.FormDestFile if m.FormProxyJump != "" { m.FormHost.Properties["ProxyJump"] = m.FormProxyJump diff --git a/internal/tui/view_table.go b/internal/tui/view_table.go index 3cfd7f4..01cd991 100644 --- a/internal/tui/view_table.go +++ b/internal/tui/view_table.go @@ -18,7 +18,6 @@ func (m *Model) renderTable(width, maxHeight int) string { var headerRow, dividerRow string var wAlias, wName, wUser, wPort, wStatus, wConfig int - // adaptive column allocation to prevent overflow at small terminal widths switch { case width >= 85: wTotal := max(width-14, 10) @@ -132,69 +131,33 @@ func (m *Model) renderRow(h *config.Host, idx int, wAlias, wName, wUser, wPort, var cells []string - // Alias cell alias := truncate(h.Alias, wAlias) - var aliasStyle lipgloss.Style - if rowActive { - aliasStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) - } else { - aliasStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) - } - cells = append(cells, renderCell(alias, aliasStyle, rowActive, wAlias)) + cells = append(cells, renderCell(alias, rowCellStyle(rowActive, "252"), rowActive, wAlias)) - // Name cell name := truncate(h.Name, wName) - var nameStyle lipgloss.Style - if rowActive { - nameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) - } else { - nameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")) - } - cells = append(cells, renderCell(name, nameStyle, rowActive, wName)) + cells = append(cells, renderCell(name, rowCellStyle(rowActive, "250"), rowActive, wName)) - // User cell if wUser > 0 { user := truncate(h.User, wUser) - var userStyle lipgloss.Style - if rowActive { - userStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) - } else { - userStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - } - cells = append(cells, renderCell(user, userStyle, rowActive, wUser)) + cells = append(cells, renderCell(user, rowCellStyle(rowActive, "245"), rowActive, wUser)) } - // Port cell if wPort > 0 { port := h.Port if port == "" { port = "22" } port = truncate(port, wPort) - var portStyle lipgloss.Style - if rowActive { - portStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) - } else { - portStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242")) - } - cells = append(cells, renderCell(port, portStyle, rowActive, wPort)) + cells = append(cells, renderCell(port, rowCellStyle(rowActive, "242"), rowActive, wPort)) } - // Config cell if wConfig > 0 { cfgNickname := strings.TrimSuffix(GetTabLabel(h.SourceFile), ".conf") cfgNickname = strings.TrimSuffix(cfgNickname, "config") cfgNickname = truncate(cfgNickname, wConfig) - var cfgStyle lipgloss.Style - if rowActive { - cfgStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) - } else { - cfgStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - } - cells = append(cells, renderCell(cfgNickname, cfgStyle, rowActive, wConfig)) + cells = append(cells, renderCell(cfgNickname, rowCellStyle(rowActive, "240"), rowActive, wConfig)) } - // Status cell if wStatus > 0 { statusCell := m.renderStatusCell(h.Alias, rowActive, wStatus) cells = append(cells, statusCell) @@ -227,3 +190,10 @@ func truncate(s string, w int) string { } return s } + +func rowCellStyle(rowActive bool, normalColor string) lipgloss.Style { + if rowActive { + return lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) + } + return lipgloss.NewStyle().Foreground(lipgloss.Color(normalColor)) +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 9b0b2b8..d5025e1 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -16,7 +16,7 @@ func ValidateAlias(str string) error { if str == "" { return errors.New("alias is required") } - if len(strings.Split(str, " ")) > 1 { + if strings.Contains(str, " ") { return errors.New("alias cannot contain spaces") } if forbiddenAliasChars.MatchString(str) { @@ -30,7 +30,7 @@ func ValidateConfigName(str string) error { if str == "" { return errors.New("config name is required") } - if len(strings.Split(str, " ")) > 1 { + if strings.Contains(str, " ") { return errors.New("config name cannot contain spaces") } if forbiddenConfigNameChars.MatchString(str) {