diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e09c5f9..c16dac5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -18,12 +18,28 @@ archives: changelog: sort: asc + use: github + groups: + - title: "Breaking Changes" + regexp: '^.*?[a-z]+(\([\w\-\.]+\))?!:.+$' + order: 0 + - title: "Features" + regexp: '^.*?feat(\([\w\-\.]+\))?!?:.+$' + order: 1 + - title: "Bug Fixes" + regexp: '^.*?fix(\([\w\-\.]+\))?!?:.+$' + order: 2 + - title: "Other" + order: 999 filters: exclude: - "^docs:" - "^test:" + - "^chore:" release: github: owner: JerryAgbesi name: skipper + header: | + See the full [CHANGELOG.md](https://github.com/JerryAgbesi/skipper/blob/v{{ .Version }}/CHANGELOG.md) for details and migration notes. diff --git a/AGENTS.md b/AGENTS.md index 95a2128..003dafa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ go test ./... - Every feature must ship with tests covering it; no feature lands without test coverage. - Add or update tests whenever behavior changes, and keep `go test ./...` green. - Update `README.md` when user-facing behavior or flags change. +- avoid using unnesessary emojis. ## Commit messages diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..906ffea --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [v0.2.0] + +### Added + +- `skipper add ` subcommand for writing a new host + entry to the SSH config. Inherits the `-c, --config` persistent flag. +- Idempotent re-runs of `skipper add` with the same alias and target now + report `host "" already exists ..., no change` instead of claiming + the entry was added. + +### Removed + +- **BREAKING**: the `-a, --add` flag on the root command has been removed. + Migrate to the `add` subcommand: + + ```bash + # before + skipper --add devone user@10.0.0.8:9000 + + # after + skipper add devone user@10.0.0.8:9000 + ``` + +## [0.1.5] + +- Previous releases are tracked in [GitHub Releases](https://github.com/JerryAgbesi/skipper/releases). + +[0.1.5]: https://github.com/JerryAgbesi/skipper/releases/tag/v0.1.5 diff --git a/README.md b/README.md index 8283531..b058e82 100644 --- a/README.md +++ b/README.md @@ -74,22 +74,27 @@ Unit tests live alongside the Go packages they exercise under `cmd/` and `intern ## Usage ``` -skipper [flags] +skipper [command] [flags] ``` | Flag | Description | |------|-------------| -| `-a, --add ` | Add a host entry to the SSH config under the given alias | | `-c, --config ` | Path to SSH config file (default: `~/.ssh/config`) | | `-f, --find [term]` | Open directly in find mode, or pre-filter hosts when a search term is provided | | `-v, --version` | Print version | | `-h, --help` | Show help | +### Commands + +| Command | Description | +|---------|-------------| +| `add ` | Add a host entry to the SSH config under the given alias | + Examples: ```bash -skipper --add devone user@ipaddress:9000 -skipper --add bastion admin@10.0.0.5 +skipper add devone user@ipaddress:9000 +skipper add bastion admin@10.0.0.5 ``` ### Keyboard Controls diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..897c300 --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var addCmd = &cobra.Command{ + Use: "add ", + Short: "Add a host entry to the SSH config", + Long: "Add a host entry to the SSH config under the given alias. The target must be in the form user@host[:port].", + Example: ` skipper add devone user@10.0.0.8:9000 + skipper add bastion admin@10.0.0.5`, + Args: cobra.ExactArgs(2), + RunE: runAdd, +} + +func runAdd(_ *cobra.Command, args []string) error { + path, err := resolveConfigPath(configPath) + if err != nil { + return err + } + + host, created, err := addHost(path, args[0], args[1]) + if err != nil { + return err + } + + if created { + fmt.Printf("added host %q for %s\n", host.Alias, hostTarget(host)) + } else { + fmt.Printf("host %q already exists for %s, no change\n", host.Alias, hostTarget(host)) + } + return nil +} + +func init() { + rootCmd.AddCommand(addCmd) +} diff --git a/cmd/add_test.go b/cmd/add_test.go new file mode 100644 index 0000000..ef9da0e --- /dev/null +++ b/cmd/add_test.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/jerryagbesi/skipper/internal/sshconfig" +) + +func TestAddCommandWritesHost(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + + out := new(bytes.Buffer) + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetArgs([]string{"add", "devone", "user@10.0.0.8:9000", "-c", path}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("expected add command to succeed, got %v", err) + } + + hosts, err := sshconfig.ParseHosts(path) + if err != nil { + t.Fatalf("expected config to parse, got %v", err) + } + + if len(hosts) != 1 || hosts[0].Alias != "devone" { + t.Fatalf("expected single devone host, got %+v", hosts) + } +} + +func TestAddCommandRejectsWrongArgCount(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + + out := new(bytes.Buffer) + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetArgs([]string{"add", "devone", "-c", path}) + + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected error for missing target argument") + } + + if !strings.Contains(err.Error(), "accepts 2 arg") { + t.Fatalf("expected arg-count error, got %v", err) + } +} diff --git a/cmd/root.go b/cmd/root.go index 59b31e0..9e17271 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,7 +15,6 @@ import ( var ( configPath string - addAlias string findQuery string ) @@ -31,22 +30,12 @@ var rootCmd = &cobra.Command{ Long: `skipper is a cli tool for managing ssh connections, It allows you to select your preferred ssh host alias, connect to it, and execute commands.`, } -func runRoot(cmd *cobra.Command, args []string) error { +func runRoot(cmd *cobra.Command, _ []string) error { path, err := resolveConfigPath(configPath) if err != nil { return err } - if cmd.Flags().Changed("add") { - host, err := addHost(path, addAlias, args) - if err != nil { - return err - } - - fmt.Printf("added host %q for %s\n", host.Alias, hostTarget(host)) - return nil - } - hosts, err := sshconfig.ParseHosts(path) if err != nil { return err @@ -100,19 +89,15 @@ func prepareHostSelection(cmd *cobra.Command, hosts []sshconfig.Host) (ui.RunOpt return options, filtered, nil } -func addHost(path, alias string, args []string) (*sshconfig.Host, error) { +func addHost(path, alias, target string) (*sshconfig.Host, bool, error) { alias = strings.TrimSpace(alias) if alias == "" { - return nil, fmt.Errorf("--add requires an alias") - } - - if len(args) != 1 { - return nil, fmt.Errorf("--add requires exactly one target in the format user@host[:port]") + return nil, false, fmt.Errorf("alias is required") } - host, err := connect.ParseTarget(args[0]) + host, err := connect.ParseTarget(target) if err != nil { - return nil, err + return nil, false, err } host.Alias = alias @@ -164,7 +149,6 @@ func Execute() { func init() { rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to ssh config file, defaults to ~/.ssh/config") - rootCmd.Flags().StringVarP(&addAlias, "add", "a", "", "add a host alias using `` user@host[:port]") rootCmd.Flags().StringVarP(&findQuery, "find", "f", "", "start in find mode or pre-filter hosts by a search term") rootCmd.Flags().Lookup("find").NoOptDefVal = "" rootCmd.Flags().BoolP("version", "v", false, "print version information") diff --git a/cmd/root_test.go b/cmd/root_test.go index f28af87..8a386b6 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -11,6 +11,8 @@ import ( "github.com/spf13/cobra" ) +const testAlias = "devone" + func TestFilterHostsReturnsOriginalListForBlankQuery(t *testing.T) { hosts := []sshconfig.Host{{Alias: "dev"}, {Alias: "prod"}} @@ -93,13 +95,17 @@ func TestPrepareHostSelectionReturnsErrorWhenNoMatch(t *testing.T) { func TestAddHostWritesAliasAndTarget(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config") - host, err := addHost(configPath, "devone", []string{"user@10.0.0.8:9000"}) + host, created, err := addHost(configPath, testAlias, "user@10.0.0.8:9000") if err != nil { t.Fatalf("expected no error, got %v", err) } - if host.Alias != "devone" { - t.Fatalf("expected alias devone, got %q", host.Alias) + if !created { + t.Fatal("expected created to be true on first add") + } + + if host.Alias != testAlias { + t.Fatalf("expected alias %q, got %q", testAlias, host.Alias) } hosts, err := sshconfig.ParseHosts(configPath) @@ -111,7 +117,7 @@ func TestAddHostWritesAliasAndTarget(t *testing.T) { t.Fatalf("expected 1 host, got %d", len(hosts)) } - if hosts[0].Alias != "devone" || hosts[0].User != "user" || hosts[0].Hostname != "10.0.0.8" || hosts[0].Port != 9000 { + if hosts[0].Alias != testAlias || hosts[0].User != "user" || hosts[0].Hostname != "10.0.0.8" || hosts[0].Port != 9000 { t.Fatalf("unexpected host written: %+v", hosts[0]) } } @@ -119,16 +125,24 @@ func TestAddHostWritesAliasAndTarget(t *testing.T) { func TestAddHostIsIdempotentForSameAliasAndTarget(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config") - firstHost, err := addHost(configPath, "devone", []string{"user@10.0.0.8:9000"}) + firstHost, firstCreated, err := addHost(configPath, testAlias, "user@10.0.0.8:9000") if err != nil { t.Fatalf("expected first add to succeed, got %v", err) } - secondHost, err := addHost(configPath, "devone", []string{"user@10.0.0.8:9000"}) + if !firstCreated { + t.Fatal("expected first add to report created=true") + } + + secondHost, secondCreated, err := addHost(configPath, testAlias, "user@10.0.0.8:9000") if err != nil { t.Fatalf("expected second add to succeed, got %v", err) } + if secondCreated { + t.Fatal("expected duplicate add to report created=false") + } + if secondHost.Alias != firstHost.Alias || secondHost.User != firstHost.User || secondHost.Hostname != firstHost.Hostname || secondHost.Port != firstHost.Port { t.Fatalf("expected same host back, got first=%+v second=%+v", firstHost, secondHost) } @@ -147,7 +161,7 @@ func TestAddHostIsIdempotentForSameAliasAndTarget(t *testing.T) { t.Fatalf("expected config to be readable, got %v", err) } - if strings.Count(string(content), "Host devone\n") != 1 { + if strings.Count(string(content), "Host "+testAlias+"\n") != 1 { t.Fatalf("expected single host entry, got content:\n%s", string(content)) } } @@ -155,11 +169,11 @@ func TestAddHostIsIdempotentForSameAliasAndTarget(t *testing.T) { func TestAddHostRejectsDuplicateAliasWithDifferentTarget(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config") - if _, err := addHost(configPath, "devone", []string{"user@10.0.0.8:9000"}); err != nil { + if _, _, err := addHost(configPath, testAlias, "user@10.0.0.8:9000"); err != nil { t.Fatalf("expected first add to succeed, got %v", err) } - _, err := addHost(configPath, "devone", []string{"user@10.0.0.9:9000"}) + _, _, err := addHost(configPath, testAlias, "user@10.0.0.9:9000") if err == nil { t.Fatal("expected duplicate alias with different target to fail") } @@ -172,25 +186,16 @@ func TestAddHostRejectsDuplicateAliasWithDifferentTarget(t *testing.T) { func TestAddHostRequiresAlias(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config") - _, err := addHost(configPath, " ", []string{"user@10.0.0.8:9000"}) + _, _, err := addHost(configPath, " ", "user@10.0.0.8:9000") if err == nil { t.Fatal("expected error for missing alias") } } -func TestAddHostRequiresExactlyOneTarget(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config") - - _, err := addHost(configPath, "devone", nil) - if err == nil { - t.Fatal("expected error for missing target") - } -} - func TestAddHostRejectsInvalidTarget(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config") - _, err := addHost(configPath, "devone", []string{"invalid-target"}) + _, _, err := addHost(configPath, testAlias, "invalid-target") if err == nil { t.Fatal("expected error for invalid target") } diff --git a/internal/sshconfig/writer.go b/internal/sshconfig/writer.go index 70da0e6..8f6b7b3 100644 --- a/internal/sshconfig/writer.go +++ b/internal/sshconfig/writer.go @@ -8,23 +8,23 @@ import ( "unicode" ) -func AddHost(path string, host Host) (addedHost *Host, err error) { +func AddHost(path string, host Host) (addedHost *Host, created bool, err error) { if strings.TrimSpace(host.Hostname) == "" { - return nil, fmt.Errorf("host name is required") + return nil, false, fmt.Errorf("host name is required") } if strings.TrimSpace(host.User) == "" { - return nil, fmt.Errorf("user is required") + return nil, false, fmt.Errorf("user is required") } host.Alias = resolveAlias(host) if err := validateHostFields(host); err != nil { - return nil, err + return nil, false, err } existingHosts, err := readExistingHosts(path) if err != nil { - return nil, err + return nil, false, err } for _, existingHost := range existingHosts { @@ -33,24 +33,24 @@ func AddHost(path string, host Host) (addedHost *Host, err error) { } if sameHostSettings(existingHost, host) { - return &existingHost, nil + return &existingHost, false, nil } - return nil, fmt.Errorf("host %q already exists in %s with different settings", host.Alias, path) + return nil, false, fmt.Errorf("host %q already exists in %s with different settings", host.Alias, path) } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return nil, fmt.Errorf("failed to create config directory: %w", err) + return nil, false, fmt.Errorf("failed to create config directory: %w", err) } currentContent, err := os.ReadFile(path) if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to read config file %q: %w", path, err) + return nil, false, fmt.Errorf("failed to read config file %q: %w", path, err) } file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { - return nil, fmt.Errorf("failed to open config file %q: %w", path, err) + return nil, false, fmt.Errorf("failed to open config file %q: %w", path, err) } defer func() { if cerr := file.Close(); cerr != nil { @@ -64,22 +64,23 @@ func AddHost(path string, host Host) (addedHost *Host, err error) { if len(currentContent) > 0 && !strings.HasSuffix(string(currentContent), "\n") { if _, err := file.WriteString("\n"); err != nil { - return nil, fmt.Errorf("failed to prepare config file %q: %w", path, err) + return nil, false, fmt.Errorf("failed to prepare config file %q: %w", path, err) } } if len(strings.TrimSpace(string(currentContent))) > 0 { if _, err := file.WriteString("\n"); err != nil { - return nil, fmt.Errorf("failed to separate config entries in %q: %w", path, err) + return nil, false, fmt.Errorf("failed to separate config entries in %q: %w", path, err) } } if _, err := file.WriteString(formatHostEntry(host)); err != nil { - return nil, fmt.Errorf("failed to write host %q to %s: %w", host.Alias, path, err) + return nil, false, fmt.Errorf("failed to write host %q to %s: %w", host.Alias, path, err) } addedHost = &host - return addedHost, nil + created = true + return addedHost, created, nil } func readExistingHosts(path string) ([]Host, error) { diff --git a/internal/sshconfig/writer_test.go b/internal/sshconfig/writer_test.go index 037b1cc..a9d96e2 100644 --- a/internal/sshconfig/writer_test.go +++ b/internal/sshconfig/writer_test.go @@ -56,7 +56,7 @@ func TestAddHostRejectsUnsafeWhitespaceInWrittenFields(t *testing.T) { t.Run(test.name, func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config") - _, err := AddHost(configPath, test.host) + _, _, err := AddHost(configPath, test.host) if err == nil { t.Fatal("expected error, got nil") } @@ -76,7 +76,7 @@ func TestAddHostRejectsUnsafeWhitespaceInWrittenFields(t *testing.T) { func TestAddHostRejectsUnsafeWhitespaceInResolvedAlias(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config") - _, err := AddHost(configPath, Host{ + _, _, err := AddHost(configPath, Host{ Hostname: "example host", User: "alice", }) @@ -92,7 +92,7 @@ func TestAddHostRejectsUnsafeWhitespaceInResolvedAlias(t *testing.T) { func TestAddHostWritesSafeIdentityFile(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config") - _, err := AddHost(configPath, Host{ + _, _, err := AddHost(configPath, Host{ Alias: "jump-box", Hostname: "example.com", User: "alice", @@ -115,7 +115,7 @@ func TestAddHostWritesSafeIdentityFile(t *testing.T) { func TestAddHostRejectsDuplicateAliasWithDifferentIdentityFile(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config") - _, err := AddHost(configPath, Host{ + _, _, err := AddHost(configPath, Host{ Alias: "jump-box", Hostname: "example.com", User: "alice", @@ -125,7 +125,7 @@ func TestAddHostRejectsDuplicateAliasWithDifferentIdentityFile(t *testing.T) { t.Fatalf("expected first host to be added, got %v", err) } - _, err = AddHost(configPath, Host{ + _, _, err = AddHost(configPath, Host{ Alias: "jump-box", Hostname: "example.com", User: "alice",