diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..00212e6 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +# Skipper pre-commit hook: runs the linter before allowing a commit. +# Enable with `make hooks` (sets core.hooksPath to .githooks). + +set -e + +if ! command -v golangci-lint >/dev/null 2>&1; then + echo "pre-commit: golangci-lint not found on PATH — install it or skip with 'git commit --no-verify'" >&2 + exit 1 +fi + +echo "pre-commit: running make lint..." +make lint diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0b5e5aa..37fba3c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,13 +3,34 @@ on: push: branches: - main + - "feat/**" + - "fix/**" + - "docs/**" pull_request: branches: - main jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.25.4" + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + test-and-build: runs-on: ubuntu-latest + needs: lint steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..9608dc9 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,38 @@ +--- +version: "2" +run: + timeout: 5m +linters: + default: none + enable: + - copyloopvar + - errcheck + - errorlint + - goconst + - gocritic + - gosec + - govet + - ineffassign + - misspell + - revive + - staticcheck + - unparam + - unused + exclusions: + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - path: _test\.go$ + linters: + - errcheck + - gosec +formatters: + enable: + - gofmt + - gofumpt +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..95a2128 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# AGENTS.md + +Guidance for AI coding agents contributing to Skipper. Derived from `CONTRIBUTING.md` — follow both. + +## Environment + +- Requires Go 1.25+. +- Build and run locally with `make run`. +- Run `make hooks` once after cloning to enable the pre-commit lint hook (`.githooks/pre-commit`). + +## Before you push + +Always run these and make sure they pass: + +```bash +make fmt +make lint +go test ./... +``` + +## Making changes + +- Branch off `main` using prefixes like `feat/`, `fix/`, or `docs/` (e.g. `feat/search-improvements`). +- Keep each PR focused on one logical change. +- Follow existing code style — do not reformat unrelated code. +- 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. + +## Commit messages + +Short, imperative mood, lowercase. Examples: + +``` +add fuzzy match scoring +fix crash when SSH config is missing +``` + +## Pull requests + +- Open against `main`. +- Describe *what* changed and *why*; link any related issue. +- Ensure CI is green before requesting review. + +## Bug reports / feature requests + +Use the issue templates. Include expected vs. actual behavior, reproduction steps, and OS + `skipper --version`. + +## Conduct and licensing + +- Abide by the [Code of Conduct](./CODE_OF_CONDUCT.md). +- Contributions are licensed under the [Apache License 2.0](./LICENSE). diff --git a/Makefile b/Makefile index 688f208..790316c 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,10 @@ lint: fmt: golangci-lint fmt -.PHONY: all build run lint fmt +hooks: + git config core.hooksPath .githooks + @echo "Git hooks path set to .githooks" + +.PHONY: all build run lint fmt hooks all: golangci-lint fmt && go build -o skipper && go run . diff --git a/README.md b/README.md index 748b5d9..8283531 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ skipper [flags] | Flag | Description | |------|-------------| -| `-a, --add ` | Add a host entry to the SSH config using a target like `user@host[:port]` | +| `-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 | @@ -110,4 +110,4 @@ skipper --add bastion admin@10.0.0.5 | `make run` | Build and run | | `make lint` | Run golangci-lint | | `make fmt` | Format code | -| `make all` | Format + Build + Run | \ No newline at end of file +| `make all` | Format + Build + Run | diff --git a/cmd/root.go b/cmd/root.go index 1d4959e..59b31e0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,9 +13,11 @@ import ( "github.com/spf13/cobra" ) -var configPath string -var addAlias string -var findQuery string +var ( + configPath string + addAlias string + findQuery string +) var version = "dev" @@ -162,7 +164,7 @@ 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 a target like user@host[:port]") + 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/internal/connect/connect.go b/internal/connect/connect.go index 79cd87b..273b4e2 100644 --- a/internal/connect/connect.go +++ b/internal/connect/connect.go @@ -9,19 +9,21 @@ import ( "github.com/jerryagbesi/skipper/internal/sshconfig" ) -type Commander func(name string, args ...string) *exec.Cmd //allows for mocking exec.Command +// Commander allows for mocking exec.Command. +type Commander func(name string, args ...string) *exec.Cmd func Connect(host *sshconfig.Host, commander Commander) error { target := host.Alias var cmd *exec.Cmd - if target != "" { + switch { + case target != "": cmd = commander("ssh", host.Alias) - } else if host.Hostname != "" { + case host.Hostname != "": target = host.Hostname cmd = commander("ssh", host.User+"@"+host.Hostname, "-p", strconv.Itoa(host.Port)) - } else { + default: return fmt.Errorf("no target specified") } diff --git a/internal/connect/connect_test.go b/internal/connect/connect_test.go index a23ecd7..41689f8 100644 --- a/internal/connect/connect_test.go +++ b/internal/connect/connect_test.go @@ -1,34 +1,12 @@ package connect import ( - "fmt" - "os" "os/exec" - "strconv" "testing" "github.com/jerryagbesi/skipper/internal/sshconfig" ) -func fakeComander(exitCode int) Commander { - return func(name string, args ...string) *exec.Cmd { - cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess") - cmd.Env = append(os.Environ(), "TEST_SSH_STUB=1", - fmt.Sprintf("TEST_SSH_EXIT_CODE=%d", exitCode)) - - return cmd - } -} - -func TestHelperProcess(t *testing.T) { - if os.Getenv("TEST_SSH_STUB") != "1" { - return - } - - exitCode, _ := strconv.Atoi(os.Getenv("TEST_SSH_EXIT_CODE")) - os.Exit(exitCode) -} - func TestConnect_WithAlias_BuildsCorrectCommand(t *testing.T) { host := &sshconfig.Host{ Alias: "bastion", @@ -41,7 +19,6 @@ func TestConnect_WithAlias_BuildsCorrectCommand(t *testing.T) { fakeCommander := func(name string, args ...string) *exec.Cmd { capturedName = name capturedArgs = args - // return a no-op command return exec.Command("true") } @@ -56,7 +33,6 @@ func TestConnect_WithAlias_BuildsCorrectCommand(t *testing.T) { } func TestConnect_NoAlias_BuildsCorrectCommand(t *testing.T) { - host := &sshconfig.Host{ Alias: "", Hostname: "10.0.0.1", @@ -66,7 +42,7 @@ func TestConnect_NoAlias_BuildsCorrectCommand(t *testing.T) { var capturedArgs []string - fakeCommander := func(name string, args ...string) *exec.Cmd { + fakeCommander := func(_ string, args ...string) *exec.Cmd { capturedArgs = args return exec.Command("true") } @@ -84,7 +60,7 @@ func TestConnect_NoAlias_BuildsCorrectCommand(t *testing.T) { func TestConnect_EmptyHost_ReturnsError(t *testing.T) { host := &sshconfig.Host{} - fakeCommander := func(name string, args ...string) *exec.Cmd { + fakeCommander := func(_ string, _ ...string) *exec.Cmd { t.Error("commander should not be called for empty host") return nil } diff --git a/internal/connect/target.go b/internal/connect/target.go index 3472216..8c26922 100644 --- a/internal/connect/target.go +++ b/internal/connect/target.go @@ -1,6 +1,7 @@ package connect import ( + "errors" "fmt" "net" "strconv" @@ -47,7 +48,8 @@ func parseHostPort(rawHost string) (string, int, error) { return strings.Trim(hostname, "[]"), port, nil } - if addrErr, ok := err.(*net.AddrError); ok && strings.Contains(addrErr.Err, "missing port in address") { + var addrErr *net.AddrError + if errors.As(err, &addrErr) && strings.Contains(addrErr.Err, "missing port in address") { if strings.HasSuffix(rawHost, ":") { return "", 0, fmt.Errorf("target must be in the format user@host[:port]") } diff --git a/internal/connect/target_test.go b/internal/connect/target_test.go index fa8629f..f354d22 100644 --- a/internal/connect/target_test.go +++ b/internal/connect/target_test.go @@ -75,4 +75,4 @@ func TestParseTarget_Invalid(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/sshconfig/parser.go b/internal/sshconfig/parser.go index d193bb1..14f862e 100644 --- a/internal/sshconfig/parser.go +++ b/internal/sshconfig/parser.go @@ -18,11 +18,11 @@ type Host struct { } func DefaultConfigPath() (string, error) { - if home, err := os.UserHomeDir(); err != nil { + home, err := os.UserHomeDir() + if err != nil { return "", fmt.Errorf("error resolving home directory: %w", err) - } else { - return filepath.Join(home, ".ssh", "config"), nil } + return filepath.Join(home, ".ssh", "config"), nil } func ParseHosts(path string) ([]Host, error) { @@ -44,7 +44,8 @@ func ParseHosts(path string) ([]Host, error) { var hosts []Host for _, host := range cfg.Hosts { - for _, pattern := range host.Patterns { //incase a user has multiple patterns for a host eg. Host bastion jump-box *.staging + // A single Host block can list multiple patterns (e.g. `Host bastion jump-box *.staging`) — iterate each. + for _, pattern := range host.Patterns { alias := pattern.String() if alias == "*" { @@ -71,10 +72,8 @@ func ParseHosts(path string) ([]Host, error) { Port: port, IdentityFile: identityFile, }) - } } return hosts, nil - } diff --git a/internal/sshconfig/parser_test.go b/internal/sshconfig/parser_test.go index a3d3a7a..75c8a5c 100644 --- a/internal/sshconfig/parser_test.go +++ b/internal/sshconfig/parser_test.go @@ -6,7 +6,6 @@ import ( func TestStandardConfigParse(t *testing.T) { hosts, err := ParseHosts("../../testdata/config1") - if err != nil { t.Fatal(err) } @@ -18,7 +17,6 @@ func TestStandardConfigParse(t *testing.T) { func TestEmptyConfig(t *testing.T) { hosts, err := ParseHosts("../../testdata/config") - if err != nil { t.Fatal(err) } @@ -30,7 +28,6 @@ func TestEmptyConfig(t *testing.T) { func TestFileNotFound(t *testing.T) { hosts, err := ParseHosts("404file") - if err == nil { t.Fatalf("expected error, got nil") } diff --git a/internal/sshconfig/writer.go b/internal/sshconfig/writer.go index 38f4b1b..70da0e6 100644 --- a/internal/sshconfig/writer.go +++ b/internal/sshconfig/writer.go @@ -57,7 +57,7 @@ func AddHost(path string, host Host) (addedHost *Host, err error) { if err == nil { err = fmt.Errorf("failed to close config file %q: %w", path, cerr) } else { - err = fmt.Errorf("%v; close error for %q: %w", err, path, cerr) + err = fmt.Errorf("%w; close error for %q: %w", err, path, cerr) } } }() @@ -96,14 +96,14 @@ func readExistingHosts(path string) ([]Host, error) { func formatHostEntry(host Host) string { var builder strings.Builder - builder.WriteString(fmt.Sprintf("Host %s\n", host.Alias)) - builder.WriteString(fmt.Sprintf(" HostName %s\n", host.Hostname)) - builder.WriteString(fmt.Sprintf(" User %s\n", host.User)) + fmt.Fprintf(&builder, "Host %s\n", host.Alias) + fmt.Fprintf(&builder, " HostName %s\n", host.Hostname) + fmt.Fprintf(&builder, " User %s\n", host.User) if host.Port > 0 { - builder.WriteString(fmt.Sprintf(" Port %d\n", host.Port)) + fmt.Fprintf(&builder, " Port %d\n", host.Port) } if host.IdentityFile != "" { - builder.WriteString(fmt.Sprintf(" IdentityFile %s\n", host.IdentityFile)) + fmt.Fprintf(&builder, " IdentityFile %s\n", host.IdentityFile) } return builder.String()