Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix spelling in this contributor guideline.

There’s a typo in “unnesessary”; it should be “unnecessary.”

Proposed patch
-- avoid using unnesessary emojis.
+- avoid using unnecessary emojis.

Based on learnings: Avoid using unnecessary emojis.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- avoid using unnesessary emojis.
- avoid using unnecessary emojis.
🧰 Tools
🪛 LanguageTool

[grammar] ~29-~29: Ensure spelling is correct
Context: ...behavior or flags change. - avoid using unnesessary emojis. ## Commit messages Short, imp...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@AGENTS.md` at line 29, Correct the spelling of "unnesessary" to "unnecessary"
in the contributor guideline phrase "avoid using unnesessary emojis."; update
the sentence to read "avoid using unnecessary emojis." and ensure any other
occurrences of "unnesessary" in AGENTS.md are similarly fixed for consistency.


## Commit messages

Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <alias> <user@host[:port]>` 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 "<alias>" 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
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias> <user@host[:port]>` | Add a host entry to the SSH config under the given alias |
| `-c, --config <path>` | 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 <alias> <user@host[:port]>` | 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
Expand Down
40 changes: 40 additions & 0 deletions cmd/add.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"
)

var addCmd = &cobra.Command{
Use: "add <alias> <user@host[:port]>",
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)
}
50 changes: 50 additions & 0 deletions cmd/add_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
26 changes: 5 additions & 21 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (

var (
configPath string
addAlias string
findQuery string
)

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<alias>` 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")
Expand Down
45 changes: 25 additions & 20 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"github.com/spf13/cobra"
)

const testAlias = "devone"

func TestFilterHostsReturnsOriginalListForBlankQuery(t *testing.T) {
hosts := []sshconfig.Host{{Alias: "dev"}, {Alias: "prod"}}

Expand Down Expand Up @@ -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)
Expand All @@ -111,24 +117,32 @@ 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])
}
}

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)
}
Expand All @@ -147,19 +161,19 @@ 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))
}
}

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")
}
Expand All @@ -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")
}
Expand Down
Loading
Loading