From 87c7f40d2dfdad50887036bbeaf77968993c3a5c Mon Sep 17 00:00:00 2001 From: Adem Baccara <71262172+Adembc@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:31:49 +0100 Subject: [PATCH 1/4] ci: add action to enforce semantic PR titles and run tests (#55) --- .github/workflows/go.yml | 39 ++++++++++++++++++++++ .github/workflows/semantic-prs.yml | 43 +++++++++++++++++++++++++ .golangci.yml | 9 +++++- README.md | 29 +++++++++++++++++ internal/adapters/ui/validation_test.go | 25 ++++++++++++++ 5 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/go.yml create mode 100644 .github/workflows/semantic-prs.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..d12a903 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,39 @@ +name: Go Build and Test + +on: + pull_request: + branches: + - main + types: + - edited + - opened + - reopened + - synchronize + push: + branches: + - main + +permissions: + contents: read + pull-requests: read + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + cache: true + + - name: Run make build + run: make build + + - name: Run make test + run: make test \ No newline at end of file diff --git a/.github/workflows/semantic-prs.yml b/.github/workflows/semantic-prs.yml new file mode 100644 index 0000000..78d04fe --- /dev/null +++ b/.github/workflows/semantic-prs.yml @@ -0,0 +1,43 @@ +name: Semantic PRs + +on: + pull_request: + types: + - edited + - opened + - reopened + - synchronize + +permissions: + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.number }} + cancel-in-progress: true + +jobs: + validate_title: + name: Validate Title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5.5.3 + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + with: + types: | + fix + feat + improve + refactor + revert + test + ci + docs + chore + + scopes: | + ui + cli + config + parser + requireScope: false \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index e44d3ac..b723bc9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -14,7 +14,14 @@ issues: - dupl - lll - + # exclude some linters for the test directory and test files + - path: test/.*|.*_test\.go + linters: + - dupl + - errcheck + - goconst + - gocyclo + - gosec linters: disable-all: true diff --git a/README.md b/README.md index 8b05377..4b50d7f 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,35 @@ Contributions are welcome! We love seeing the community make Lazyssh better 🚀 +### Semantic Pull Requests + +This repository enforces semantic PR titles via an automated GitHub Action. Please format your PR title as: + +- type(scope): short descriptive subject +Notes: +- Scope is optional and should be one of: ui, cli, config, parser. + +Allowed types in this repo: +- feat: a new feature +- fix: a bug fix +- improve: quality or UX improvements that are not a refactor or perf +- refactor: code change that neither fixes a bug nor adds a feature +- docs: documentation only changes +- test: adding or refactoring tests +- ci: CI/CD or automation changes +- chore: maintenance tasks, dependency bumps, non-code infra +- revert: reverts a previous commit + +Examples: +- feat(ui): add server pinning and sorting options +- fix(parser): handle comments at end of Host blocks +- improve(cli): show friendly error when ssh binary missing +- refactor(config): simplify backup rotation logic +- docs: add installation instructions for Homebrew +- ci: cache Go toolchain and dependencies + +Tip: If your PR touches multiple areas, pick the most relevant scope or omit the scope. + --- ## ⭐ Support diff --git a/internal/adapters/ui/validation_test.go b/internal/adapters/ui/validation_test.go index 9968372..de33639 100644 --- a/internal/adapters/ui/validation_test.go +++ b/internal/adapters/ui/validation_test.go @@ -15,6 +15,8 @@ package ui import ( + "os" + "path/filepath" "testing" ) @@ -134,6 +136,29 @@ func TestValidateBindAddress(t *testing.T) { } func TestValidateKeyPaths(t *testing.T) { + // Prepare an isolated HOME with a mock .ssh folder and key files + oldHome := os.Getenv("HOME") + t.Cleanup(func() { + _ = os.Setenv("HOME", oldHome) + }) + + tempHome := t.TempDir() + sshDir := filepath.Join(tempHome, ".ssh") + if err := os.MkdirAll(sshDir, 0o755); err != nil { + t.Fatalf("failed to create temp .ssh dir: %v", err) + } + + shouldExistFiles := []string{"id_rsa", "id_ed25519"} + for _, name := range shouldExistFiles { + p := filepath.Join(sshDir, name) + if err := os.WriteFile(p, []byte("test"), 0o644); err != nil { + t.Fatalf("failed to create mock key file %s: %v", p, err) + } + } + if err := os.Setenv("HOME", tempHome); err != nil { + t.Fatalf("failed to set HOME: %v", err) + } + tests := []struct { name string keys string From 1da7c07d05adbabcca647fb0c862219a7b764df5 Mon Sep 17 00:00:00 2001 From: Michel Belleau Date: Fri, 19 Sep 2025 17:25:20 -0400 Subject: [PATCH 2/4] feat(ui): add Install SSH Key action (K) using ssh-copy-id Adds per-server action bound to uppercase K to install the local public key into the selected host's authorized_keys via ssh-copy-id. Runs interactively via app.Suspend with stdin/stdout/stderr wired. Shows status messages and hints; README and hint bar updated. Existing k (navigate up) and s (sort) remain unchanged. --- README.md | 2 +- internal/adapters/ui/handlers.go | 14 +++++ internal/adapters/ui/hint_bar.go | 2 +- internal/core/ports/services.go | 1 + internal/core/services/server_service.go | 72 ++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4b50d7f..37429a4 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ make run | Enter | SSH into selected server | | c | Copy SSH command to clipboard | | g | Ping selected server | +| K | Install SSH Key (authorized_keys) | | r | Refresh background data | | a | Add server | | e | Edit server | @@ -246,4 +247,3 @@ If you find Lazyssh useful, please consider giving the repo a **star** ⭐️ an - Built with [tview](https://github.com/rivo/tview) and [tcell](https://github.com/gdamore/tcell). - Inspired by [k9s](https://github.com/derailed/k9s) and [lazydocker](https://github.com/jesseduffield/lazydocker). - diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 7b1becd..8668fa9 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -78,6 +78,9 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey { case 'k': t.handleNavigateUp() return nil + case 'K': + t.handleInstallSSHKey() + return nil } if event.Key() == tcell.KeyEnter { @@ -256,6 +259,17 @@ func (t *tui) handlePingSelected() { } } +func (t *tui) handleInstallSSHKey() { + if server, ok := t.serverList.GetSelectedServer(); ok { + alias := server.Alias + t.showStatusTemp(fmt.Sprintf("Installing key to %s…", alias)) + t.app.Suspend(func() { + _ = t.serverService.CopySSHKey(alias) + }) + t.refreshServerList() + } +} + func (t *tui) handleModalClose() { t.returnToMain() } diff --git a/internal/adapters/ui/hint_bar.go b/internal/adapters/ui/hint_bar.go index de94973..a7c445c 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 • g Ping • K Install Key • r Refresh • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort[-]") return hint } diff --git a/internal/core/ports/services.go b/internal/core/ports/services.go index 9394751..e9aa1aa 100644 --- a/internal/core/ports/services.go +++ b/internal/core/ports/services.go @@ -27,5 +27,6 @@ type ServerService interface { DeleteServer(server domain.Server) error SetPinned(alias string, pinned bool) error SSH(alias string) error + CopySSHKey(alias string) error Ping(server domain.Server) (bool, time.Duration, error) } diff --git a/internal/core/services/server_service.go b/internal/core/services/server_service.go index c01b18b..b557197 100644 --- a/internal/core/services/server_service.go +++ b/internal/core/services/server_service.go @@ -168,6 +168,78 @@ func (s *serverService) SSH(alias string) error { return nil } +func (s *serverService) CopySSHKey(alias string) error { + s.logger.Infow("ssh-copy-id start", "alias", alias) + + // Ensure ssh-copy-id exists on PATH + if _, err := exec.LookPath("ssh-copy-id"); err != nil { + s.logger.Errorw("ssh-copy-id missing", "error", err) + return fmt.Errorf("ssh-copy-id not found; install OpenSSH (e.g., brew install openssh)") + } + + // Resolve host, port, and user via `ssh -G ` + host := "" + port := 0 + user := "" + cmd := exec.Command("ssh", "-G", alias) + out, err := cmd.Output() + if err == nil { + scanner := bufio.NewScanner(strings.NewReader(string(out))) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "hostname ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + host = parts[1] + } + } + if strings.HasPrefix(line, "port ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + if p, err := strconv.Atoi(parts[1]); err == nil { + port = p + } + } + } + if strings.HasPrefix(line, "user ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + user = parts[1] + } + } + } + } + if strings.TrimSpace(host) == "" { + host = alias + } + if port == 0 { + port = 22 + } + + target := host + if strings.TrimSpace(user) != "" { + target = user + "@" + host + } + + args := []string{} + if port != 22 { + args = append(args, "-p", strconv.Itoa(port)) + } + args = append(args, target) + + copyCmd := exec.Command("ssh-copy-id", args...) + copyCmd.Stdin = os.Stdin + copyCmd.Stdout = os.Stdout + copyCmd.Stderr = os.Stderr + if err := copyCmd.Run(); err != nil { + s.logger.Errorw("ssh-copy-id failed", "alias", alias, "error", err) + return err + } + + s.logger.Infow("ssh-copy-id end", "alias", alias) + return nil +} + // Ping checks if the server is reachable on its SSH port. func (s *serverService) Ping(server domain.Server) (bool, time.Duration, error) { start := time.Now() From 67d0e24de977b47d8c19efd6b6d160a6eebe47db Mon Sep 17 00:00:00 2001 From: Michel Belleau Date: Fri, 19 Sep 2025 17:32:59 -0400 Subject: [PATCH 3/4] fix(ui): add missing K shortcut in Server Details commands list Adds 'K: Install SSH Key' entry to the right-side Commands panel for consistency with the top hint bar and README. --- internal/adapters/ui/server_details.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index 8e0a634..6f82a32 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 g: Ping server\n K: Install SSH Key\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) } From c055238e9fc2bcf3f6f56fcc588b1ce08231f738 Mon Sep 17 00:00:00 2001 From: Michel Belleau Date: Fri, 19 Sep 2025 17:50:05 -0400 Subject: [PATCH 4/4] fix(cmd): Uncomplicate and just use the alias --- internal/core/services/server_service.go | 59 ++---------------------- 1 file changed, 5 insertions(+), 54 deletions(-) diff --git a/internal/core/services/server_service.go b/internal/core/services/server_service.go index b557197..b4542f0 100644 --- a/internal/core/services/server_service.go +++ b/internal/core/services/server_service.go @@ -177,61 +177,12 @@ func (s *serverService) CopySSHKey(alias string) error { return fmt.Errorf("ssh-copy-id not found; install OpenSSH (e.g., brew install openssh)") } - // Resolve host, port, and user via `ssh -G ` - host := "" - port := 0 - user := "" - cmd := exec.Command("ssh", "-G", alias) - out, err := cmd.Output() - if err == nil { - scanner := bufio.NewScanner(strings.NewReader(string(out))) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "hostname ") { - parts := strings.Fields(line) - if len(parts) >= 2 { - host = parts[1] - } - } - if strings.HasPrefix(line, "port ") { - parts := strings.Fields(line) - if len(parts) >= 2 { - if p, err := strconv.Atoi(parts[1]); err == nil { - port = p - } - } - } - if strings.HasPrefix(line, "user ") { - parts := strings.Fields(line) - if len(parts) >= 2 { - user = parts[1] - } - } - } - } - if strings.TrimSpace(host) == "" { - host = alias - } - if port == 0 { - port = 22 - } - - target := host - if strings.TrimSpace(user) != "" { - target = user + "@" + host - } + cmd := exec.Command("ssh-copy-id", alias) - args := []string{} - if port != 22 { - args = append(args, "-p", strconv.Itoa(port)) - } - args = append(args, target) - - copyCmd := exec.Command("ssh-copy-id", args...) - copyCmd.Stdin = os.Stdin - copyCmd.Stdout = os.Stdout - copyCmd.Stderr = os.Stderr - if err := copyCmd.Run(); err != nil { + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { s.logger.Errorw("ssh-copy-id failed", "alias", alias, "error", err) return err }