implement core logic (#3)

This commit is contained in:
Adem Baccara
2025-08-27 16:56:42 +01:00
committed by GitHub
parent e9225188e8
commit 8e3f7ebc37
27 changed files with 1675 additions and 245 deletions
+1
View File
@@ -33,3 +33,4 @@ go.work.sum
brainstorm brainstorm
.idea .idea
bin bin
.DS_Store
-10
View File
@@ -15,14 +15,6 @@ issues:
- lll - lll
# exclude some linters for the test directory and test files
- path: test/.*|.*_test\.go
linters:
- dupl
- errcheck
- goconst
- gocyclo
- gosec
linters: linters:
disable-all: true disable-all: true
@@ -76,8 +68,6 @@ linters-settings:
- whyNoLint - whyNoLint
- hugeParam - hugeParam
goimports:
local-prefixes: github.com/kubeflow/notebooks/workspaces/backend
goheader: goheader:
template: |- template: |-
+90 -59
View File
@@ -1,74 +1,105 @@
# lazyssh <div align="center">
<img src="./docs/logo.png" alt="lazyssh logo" width="600" height="600"/>
**lazyssh** is a terminal-based, interactive SSH manager inspired by tools like **lazydocker** and **k9s** — but built </div>
for managing your fleet of servers directly from your terminal.
With **lazyssh**, you can quickly **navigate**, **connect**, **manage**, and **transfer files** between your local
machine and any server defined in your `~/.ssh/config`.
No more remembering IP addresses or running long `scp` commands — just a clean, keyboard-driven UI.
--- ---
Lazyssh is a terminal-based, interactive SSH manager inspired by tools like lazydocker and k9s — but built for managing your fleet of servers directly from your terminal.
<br/>
With lazyssh, you can quickly navigate, connect, manage, and transfer files between your local machine and any server defined in your ~/.ssh/config. No more remembering IP addresses or running long scp commands — just a clean, keyboard-driven UI.
## ✨ Features ## ✨ Features
### Server Management ### Server Management (current)
- 📜 Read & display servers from your `~/.ssh/config` in a scrollable list.
- Add a new server from the UI by specifying alias, host/IP, username, port, identity file.
- ✏ Edit existing server entries directly from the UI.
- 🗑 Delete server entries safely.
- 📌 Pin / unpin servers to keep favorites at the top.
- 🏓 Ping server to check status.
- 📜 **Read & display** servers from your `~/.ssh/config` in a scrollable list. ### Quick Server Navigation
- **Add** a new server entry from the UI by specifying: - 🔍 Fuzzy search by alias, IP, or tags.
- Host alias - 🖥 Onekeypress SSH into the selected server (Enter).
- HostName / IP - 🏷 Tag servers (e.g., prod, dev, test) for quick filtering.
- Username - ↕️ Sort by alias or last SSH (toggle + reverse).
- Port
- Identity file
-**Edit** existing server entries directly from the UI.
- 🗑 **Delete** server entries safely.
### **Quick Server Navigation**
- 🔍 **Fuzzy search** through servers by alias or IP.
- ⏩ Instant SSH into selected server with a single keypress.
- 🏷 Grouping/tagging of servers (e.g., `prod`, `dev`, `test`) for quick filtering.
### **Remote Operations**
- 🖥 **Open Terminal**: Start an SSH session instantly.
- 📤 **Copy from server → local**: Select remote file/folder, choose local destination.
- 📥 **Copy from local → server**: Select local file/folder, choose remote destination.
### **Port Forwarding**
- 📡 Easily forward local ports to remote services (and vice versa) from the UI.
- Save & reuse common port forwarding setups.
### **SSH Key Management**
- 🔑 **Deploy public keys** to selected servers directly from the UI.
- Choose one of three modes:
- Use your default local public key (`~/.ssh/id_ed25519.pub` or `~/.ssh/id_rsa.pub`)
- Paste a custom public key manually
- Generate a new keypair and deploy it
- Automatically append the key to `~/.ssh/authorized_keys` with correct permissions.
### Upcoming
- 📁 Copy files between local and servers with an easy picker UI.
- 📡 Port forwarding (local↔remote) from the UI.
- 🔑 Enhanced Key Management:
- Use default local public key (~/.ssh/id_ed25519.pub or ~/.ssh/id_rsa.pub)
- Paste custom public keys manually
- Generate new keypairs and deploy them
- Automatically append keys to ~/.ssh/authorized_keys with correct permissions
--- ---
## 🎯 Use Cases ## 🛠 Installation
- Developers switching between dozens of dev/test/staging/production VMs - From source (requires Go 1.22+):
- Sysadmins managing multiple environments and needing quick access - git clone https://github.com/Adembc/lazyssh.git
- Anyone who wants **fast, zero-hassle SSH management** without memorizing IPs - cd lazyssh
- go build -o lazyssh ./cmd
---
## 🚀 Usage
- Launch TUI (default):
- ./lazyssh
- Show version:
- ./lazyssh -v
- ./lazyssh --version - ./lazyssh --version
- ./lazyssh version
- List servers in terminal: - Using make (if available):
- ./lazyssh list - make build
- ./bin/lazyssh
Binary releases: if/when releases are published, download from the Releases page and place in your PATH.
--- ---
## ⚙️ Configuration
lazyssh reads your SSH hosts from `~/.ssh/config`. Example entry:
```Host my-server
HostName 203.0.113.10
User ubuntu
Port 22
IdentityFile ~/.ssh/id_ed25519
```
You can add/edit/delete entries from within the UI as well; lazyssh will keep things consistent.
---
## ⌨️ Key Bindings
| Key | Action |
|---|---|
| / | Toggle search bar |
| ↑/↓ | Navigate servers |
| Enter | SSH into selected server |
| c | Copy SSH command to clipboard |
| g | Ping selected server |
| r | Refresh background data |
| a | Add server |
| e | Edit server |
| t | Edit tags |
| d | Delete server |
| p | Pin/Unpin server |
| s | Toggle sort field |
| S | Reverse sort order |
| q | Quit |
Tip: The hint bar at the top of the list shows the most useful shortcuts.
---
## 🚀 Quickstart
- Ensure your `~/.ssh/config` contains at least one Host.
- Run the app: `./lazyssh`
- Use `/` to search, `Enter` to connect.
---
## 🙏 Acknowledgments
- 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).
+25 -4
View File
@@ -17,9 +17,12 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"time" "time"
"github.com/Adembc/lazyssh/internal/adapters/data/memory" "github.com/Adembc/lazyssh/internal/adapters/data/file"
"github.com/Adembc/lazyssh/internal/logger"
"github.com/Adembc/lazyssh/internal/adapters/ui" "github.com/Adembc/lazyssh/internal/adapters/ui"
"github.com/Adembc/lazyssh/internal/core/services" "github.com/Adembc/lazyssh/internal/core/services"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -32,9 +35,27 @@ var (
) )
func main() { func main() {
serverInMemoryRepo := memory.NewServerRepository() log, err := logger.New("LAZYSSH")
serverService := services.NewServerService(serverInMemoryRepo) if err != nil {
tui := ui.NewTUI(serverService, version, gitCommit, buildTime) fmt.Println(err)
os.Exit(1)
}
//nolint:errcheck // log.Sync may return an error which is safe to ignore here
defer log.Sync()
home, err := os.UserHomeDir()
if err != nil {
log.Errorw("failed to get user home directory", "error", err)
//nolint:gocritic // exitAfterDefer: ensure immediate exit on unrecoverable error
os.Exit(1)
}
sshConfigFile := filepath.Join(home, ".ssh", "config")
metaDataFile := filepath.Join(home, ".lazyssh", "metadata.json")
serverRepo := file.NewServerRepo(log, sshConfigFile, metaDataFile)
serverService := services.NewServerService(log, serverRepo)
tui := ui.NewTUI(log, serverService, version, gitCommit, buildTime)
rootCmd := &cobra.Command{ rootCmd := &cobra.Command{
Use: ui.AppName, Use: ui.AppName,
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+6 -3
View File
@@ -1,20 +1,23 @@
module github.com/Adembc/lazyssh module github.com/Adembc/lazyssh
go 1.24.4 go 1.24.6
require ( require (
github.com/gdamore/tcell/v2 v2.8.1 github.com/atotto/clipboard v0.1.4
github.com/gdamore/tcell/v2 v2.9.0
github.com/mattn/go-runewidth v0.0.16
github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
go.uber.org/zap v1.27.0
) )
require ( require (
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.7 // indirect github.com/spf13/pflag v1.0.7 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/term v0.34.0 // indirect golang.org/x/term v0.34.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.28.0 // indirect
+17 -35
View File
@@ -1,19 +1,23 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= github.com/gdamore/tcell/v2 v2.9.0 h1:N6t+eqK7/xwtRPwxzs1PXeRWnm0H9l02CrgJ7DLn1ys=
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= github.com/gdamore/tcell/v2 v2.9.0/go.mod h1:8/ZoqM9rxzYphT9tH/9LnunhV9oPBqwS8WHGYm5nrmo=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb h1:n7UJ8X9UnrTZBYXnd1kAIBc067SWyuPIrsocjketYW8= github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb h1:n7UJ8X9UnrTZBYXnd1kAIBc067SWyuPIrsocjketYW8=
github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -22,73 +26,51 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,151 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package file
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/Adembc/lazyssh/internal/core/domain"
)
type ServerMetadata struct {
Tags []string `json:"tags,omitempty"`
LastSeen string `json:"last_seen,omitempty"`
PinnedAt string `json:"pinned_at,omitempty"`
SSHCount int `json:"ssh_count,omitempty"`
}
type metadataManager struct {
filePath string
}
func newMetadataManager(filePath string) *metadataManager {
return &metadataManager{filePath: filePath}
}
func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) {
metadata := make(map[string]ServerMetadata)
if _, err := os.Stat(m.filePath); os.IsNotExist(err) {
return metadata, nil
}
data, err := os.ReadFile(m.filePath)
if err != nil {
return nil, err
}
if len(data) == 0 {
return metadata, nil
}
if err := json.Unmarshal(data, &metadata); err != nil {
return nil, fmt.Errorf("failed to parse metadata JSON: %w", err)
}
return metadata, nil
}
func (m *metadataManager) saveAll(metadata map[string]ServerMetadata) error {
if err := m.ensureDirectory(); err != nil {
return err
}
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return err
}
return os.WriteFile(m.filePath, data, 0o600)
}
func (m *metadataManager) updateServer(server domain.Server) error {
metadata, err := m.loadAll()
if err != nil {
metadata = make(map[string]ServerMetadata)
}
existing := metadata[server.Alias]
merged := existing
if server.Tags != nil {
merged.Tags = server.Tags
}
if !server.LastSeen.IsZero() {
merged.LastSeen = server.LastSeen.Format(time.RFC3339)
}
if !server.PinnedAt.IsZero() {
merged.PinnedAt = server.PinnedAt.Format(time.RFC3339)
}
if server.SSHCount > 0 {
merged.SSHCount = server.SSHCount
}
metadata[server.Alias] = merged
return m.saveAll(metadata)
}
func (m *metadataManager) deleteServer(alias string) error {
metadata, err := m.loadAll()
if err != nil {
return nil
}
delete(metadata, alias)
return m.saveAll(metadata)
}
func (m *metadataManager) setPinned(alias string, pinned bool) error {
metadata, err := m.loadAll()
if err != nil {
metadata = make(map[string]ServerMetadata)
}
meta := metadata[alias]
if pinned {
meta.PinnedAt = time.Now().Format(time.RFC3339)
} else {
meta.PinnedAt = ""
}
metadata[alias] = meta
return m.saveAll(metadata)
}
func (m *metadataManager) recordSSH(alias string) error {
metadata, err := m.loadAll()
if err != nil {
metadata = make(map[string]ServerMetadata)
}
meta := metadata[alias]
meta.LastSeen = time.Now().Format(time.RFC3339)
meta.SSHCount++
metadata[alias] = meta
return m.saveAll(metadata)
}
func (m *metadataManager) ensureDirectory() error {
dir := filepath.Dir(m.filePath)
return os.MkdirAll(dir, 0o750)
}
+119
View File
@@ -0,0 +1,119 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package file
import (
"bufio"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/Adembc/lazyssh/internal/core/domain"
)
const (
sshConfigAliasField = "host"
sshConfigIPField = "hostname"
sshConfigUserField = "user"
sshConfigPortField = "port"
sshConfigKeyField = "identityfile"
)
type SSHConfigParser struct{}
func (p *SSHConfigParser) Parse(reader io.Reader) ([]domain.Server, error) {
servers := make([]domain.Server, 0)
var currentServer *domain.Server
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if p.shouldSkipLine(line) {
continue
}
key, value := p.parseKeyValue(line)
if key == "" {
continue
}
switch key {
case sshConfigAliasField:
if currentServer != nil {
servers = append(servers, *currentServer)
}
currentServer = &domain.Server{
Alias: value,
Port: DefaultPort,
}
case sshConfigIPField:
if currentServer != nil {
currentServer.Host = value
}
case sshConfigUserField:
if currentServer != nil {
currentServer.User = value
}
case sshConfigPortField:
if currentServer != nil {
currentServer.Port = p.parsePort(value)
}
case sshConfigKeyField:
if currentServer != nil {
currentServer.Key = p.expandPath(value)
}
}
}
if currentServer != nil {
servers = append(servers, *currentServer)
}
return servers, scanner.Err()
}
func (p *SSHConfigParser) shouldSkipLine(line string) bool {
return line == "" || strings.HasPrefix(line, "#")
}
func (p *SSHConfigParser) parseKeyValue(line string) (string, string) {
parts := strings.Fields(line)
if len(parts) < 2 {
return "", ""
}
key := strings.ToLower(parts[0])
value := strings.Join(parts[1:], " ")
return key, value
}
func (p *SSHConfigParser) parsePort(value string) int {
if port, err := strconv.Atoi(value); err == nil {
return port
}
return DefaultPort
}
func (p *SSHConfigParser) expandPath(path string) string {
if strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, path[2:])
}
}
return path
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package file
import (
"fmt"
"strings"
"time"
"github.com/Adembc/lazyssh/internal/core/domain"
"go.uber.org/zap"
)
type serverRepo struct {
sshConfigManager *sshConfigManager
metadataManager *metadataManager
logger *zap.SugaredLogger
}
func NewServerRepo(logger *zap.SugaredLogger, sshPath, metaDataPath string) *serverRepo {
return &serverRepo{
sshConfigManager: newSSHConfigManager(sshPath),
metadataManager: newMetadataManager(metaDataPath),
logger: logger,
}
}
func (s *serverRepo) ListServers(query string) ([]domain.Server, error) {
servers, err := s.sshConfigManager.parseServers()
if err != nil {
return nil, fmt.Errorf("failed to parse SSH config: %w", err)
}
metadata, err := s.metadataManager.loadAll()
if err != nil {
s.logger.Warnf("Failed to load metadata: %v", err)
metadata = make(map[string]ServerMetadata)
}
servers = s.mergeMetadata(servers, metadata)
if query != "" {
servers = s.filterServers(servers, query)
}
return servers, nil
}
func (s *serverRepo) UpdateServer(server domain.Server, newServer domain.Server) error {
if err := s.sshConfigManager.updateServer(server.Alias, newServer); err != nil {
return fmt.Errorf("failed to update SSH config: %w", err)
}
return s.metadataManager.updateServer(newServer)
}
func (s *serverRepo) AddServer(server domain.Server) error {
if err := s.sshConfigManager.addServer(server); err != nil {
return fmt.Errorf("failed to add to SSH config: %w", err)
}
return s.metadataManager.updateServer(server)
}
func (s *serverRepo) DeleteServer(server domain.Server) error {
if err := s.sshConfigManager.deleteServer(server.Alias); err != nil {
return fmt.Errorf("failed to delete from SSH config: %w", err)
}
return s.metadataManager.deleteServer(server.Alias)
}
func (s *serverRepo) SetPinned(alias string, pinned bool) error {
return s.metadataManager.setPinned(alias, pinned)
}
func (s *serverRepo) RecordSSH(alias string) error {
return s.metadataManager.recordSSH(alias)
}
func (s *serverRepo) mergeMetadata(servers []domain.Server, metadata map[string]ServerMetadata) []domain.Server {
for i, server := range servers {
servers[i].LastSeen = time.Time{}
if meta, exists := metadata[server.Alias]; exists {
servers[i].Tags = meta.Tags
servers[i].SSHCount = meta.SSHCount
if meta.LastSeen != "" {
if lastSeen, err := time.Parse(time.RFC3339, meta.LastSeen); err == nil {
servers[i].LastSeen = lastSeen
}
}
if meta.PinnedAt != "" {
if pinnedAt, err := time.Parse(time.RFC3339, meta.PinnedAt); err == nil {
servers[i].PinnedAt = pinnedAt
}
}
}
}
return servers
}
func (s *serverRepo) filterServers(servers []domain.Server, query string) []domain.Server {
queryLower := strings.ToLower(query)
filtered := make([]domain.Server, 0)
for _, server := range servers {
if s.matchesQuery(server, queryLower) {
filtered = append(filtered, server)
}
}
return filtered
}
func (s *serverRepo) matchesQuery(server domain.Server, queryLower string) bool {
if strings.Contains(strings.ToLower(server.Alias), queryLower) ||
strings.Contains(strings.ToLower(server.Host), queryLower) ||
strings.Contains(strings.ToLower(server.User), queryLower) {
return true
}
for _, tag := range server.Tags {
if strings.Contains(strings.ToLower(tag), queryLower) {
return true
}
}
return false
}
@@ -0,0 +1,137 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package file
import (
"fmt"
"os"
"path/filepath"
"github.com/Adembc/lazyssh/internal/core/domain"
)
const (
ManagedByComment = "# Managed by lazyssh"
DefaultPort = 22
)
type sshConfigManager struct {
filePath string
}
func newSSHConfigManager(filePath string) *sshConfigManager {
return &sshConfigManager{filePath: filePath}
}
func (m *sshConfigManager) parseServers() ([]domain.Server, error) {
file, err := os.Open(m.filePath)
if err != nil {
if os.IsNotExist(err) {
return []domain.Server{}, nil
}
return nil, err
}
defer func() {
_ = file.Close()
}()
parser := &SSHConfigParser{}
return parser.Parse(file)
}
func (m *sshConfigManager) writeServers(servers []domain.Server) error {
if err := m.ensureDirectory(); err != nil {
return err
}
file, err := os.Create(m.filePath)
if err != nil {
return err
}
defer func() {
_ = file.Close()
}()
writer := &SSHConfigWriter{}
return writer.Write(file, servers)
}
func (m *sshConfigManager) addServer(server domain.Server) error {
servers, err := m.parseServers()
if err != nil {
return err
}
// Check for duplicates
for _, srv := range servers {
if srv.Alias == server.Alias {
return fmt.Errorf("server with alias '%s' already exists", server.Alias)
}
}
servers = append(servers, server)
return m.writeServers(servers)
}
func (m *sshConfigManager) updateServer(alias string, newServer domain.Server) error {
servers, err := m.parseServers()
if err != nil {
return err
}
found := false
for i, srv := range servers {
if srv.Alias == alias {
servers[i] = newServer
found = true
break
}
}
if !found {
return fmt.Errorf("server with alias '%s' not found", alias)
}
return m.writeServers(servers)
}
func (m *sshConfigManager) deleteServer(alias string) error {
servers, err := m.parseServers()
if err != nil {
return err
}
newServers := make([]domain.Server, 0, len(servers))
found := false
for _, srv := range servers {
if srv.Alias != alias {
newServers = append(newServers, srv)
} else {
found = true
}
}
if !found {
return fmt.Errorf("server with alias '%s' not found", alias)
}
return m.writeServers(newServers)
}
func (m *sshConfigManager) ensureDirectory() error {
dir := filepath.Dir(m.filePath)
return os.MkdirAll(dir, 0o700)
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package file
import (
"bufio"
"fmt"
"io"
"github.com/Adembc/lazyssh/internal/core/domain"
)
type SSHConfigWriter struct{}
func (w *SSHConfigWriter) Write(writer io.Writer, servers []domain.Server) error {
bufWriter := bufio.NewWriter(writer)
defer func() {
_ = bufWriter.Flush()
}()
if _, err := fmt.Fprintf(bufWriter, "%s\n\n", ManagedByComment); err != nil {
return err
}
for i, server := range servers {
if i > 0 {
if _, err := bufWriter.WriteString("\n"); err != nil {
return err
}
}
if err := w.writeServer(bufWriter, server); err != nil {
return err
}
}
return nil
}
func (w *SSHConfigWriter) writeServer(writer *bufio.Writer, server domain.Server) error {
if _, err := fmt.Fprintf(writer, "Host %s\n", server.Alias); err != nil {
return err
}
if server.Host != "" {
if _, err := fmt.Fprintf(writer, " HostName %s\n", server.Host); err != nil {
return err
}
}
if server.User != "" {
if _, err := fmt.Fprintf(writer, " User %s\n", server.User); err != nil {
return err
}
}
if server.Port != 0 && server.Port != DefaultPort {
if _, err := fmt.Fprintf(writer, " Port %d\n", server.Port); err != nil {
return err
}
}
if server.Key != "" {
if _, err := fmt.Fprintf(writer, " IdentityFile %s\n", server.Key); err != nil {
return err
}
}
return nil
}
@@ -19,25 +19,31 @@ import (
"strings" "strings"
"time" "time"
"go.uber.org/zap"
"github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/domain"
) )
type serverRepository struct{} type serverRepository struct {
logger *zap.SugaredLogger
}
var servers = []domain.Server{ var servers = []domain.Server{
{Alias: "web-01", Host: "192.168.1.10", User: "root", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "web"}, Status: "online", LastSeen: time.Now().Add(-2 * time.Hour)}, {Alias: "web-01", Host: "192.168.1.10", User: "root", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "web"}, LastSeen: time.Now().Add(-2 * time.Hour)},
{Alias: "web-02", Host: "192.168.1.11", User: "ubuntu", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"prod", "web"}, Status: "warn", LastSeen: time.Now().Add(-30 * time.Minute)}, {Alias: "web-02", Host: "192.168.1.11", User: "ubuntu", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"prod", "web"}, LastSeen: time.Now().Add(-30 * time.Minute)},
{Alias: "db-01", Host: "192.168.1.20", User: "postgres", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "db"}, Status: "offline", LastSeen: time.Now().Add(-26 * time.Hour)}, {Alias: "db-01", Host: "192.168.1.20", User: "postgres", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "db"}, LastSeen: time.Now().Add(-26 * time.Hour)},
{Alias: "api-01", Host: "192.168.1.30", User: "deploy", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"prod", "api"}, Status: "online", LastSeen: time.Now().Add(-10 * time.Minute)}, {Alias: "api-01", Host: "192.168.1.30", User: "deploy", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"prod", "api"}, LastSeen: time.Now().Add(-10 * time.Minute)},
{Alias: "cache-01", Host: "192.168.1.40", User: "redis", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "cache"}, Status: "online", LastSeen: time.Now().Add(-1 * time.Hour)}, {Alias: "cache-01", Host: "192.168.1.40", User: "redis", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "cache"}, LastSeen: time.Now().Add(-1 * time.Hour)},
{Alias: "dev-web", Host: "10.0.1.10", User: "dev", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"dev", "web"}, Status: "online", LastSeen: time.Now().Add(-5 * time.Minute)}, {Alias: "dev-web", Host: "10.0.1.10", User: "dev", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"dev", "web"}, LastSeen: time.Now().Add(-5 * time.Minute)},
{Alias: "dev-db", Host: "10.0.1.20", User: "dev", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"dev", "db"}, Status: "online", LastSeen: time.Now().Add(-15 * time.Minute)}, {Alias: "dev-db", Host: "10.0.1.20", User: "dev", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"dev", "db"}, LastSeen: time.Now().Add(-15 * time.Minute)},
{Alias: "staging", Host: "staging.example.com", User: "ubuntu", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"test"}, Status: "warn", LastSeen: time.Now().Add(-45 * time.Minute)}, {Alias: "staging", Host: "staging.example.com", User: "ubuntu", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"test"}, LastSeen: time.Now().Add(-45 * time.Minute)},
} }
// NewServerRepository creates a new server repository with the given file path. // NewServerRepository creates a new server repository with the given file path.
func NewServerRepository() *serverRepository { func NewServerRepository(logger *zap.SugaredLogger) *serverRepository {
return &serverRepository{} return &serverRepository{
logger: logger,
}
} }
// ListServers returns a list of servers from the repository. // ListServers returns a list of servers from the repository.
@@ -52,11 +58,10 @@ func (r *serverRepository) ListServers(query string) ([]domain.Server, error) {
alias := strings.ToLower(server.Alias) alias := strings.ToLower(server.Alias)
host := strings.ToLower(server.Host) host := strings.ToLower(server.Host)
user := strings.ToLower(server.User) user := strings.ToLower(server.User)
status := strings.ToLower(server.Status)
port := strconv.Itoa(server.Port) port := strconv.Itoa(server.Port)
match := false match := false
if strings.Contains(alias, q) || strings.Contains(host, q) || strings.Contains(user, q) || strings.Contains(status, q) || strings.Contains(port, q) { if strings.Contains(alias, q) || strings.Contains(host, q) || strings.Contains(user, q) || strings.Contains(port, q) {
match = true match = true
} }
if !match { if !match {
@@ -101,3 +106,27 @@ func (r *serverRepository) DeleteServer(server domain.Server) error {
} }
return nil return nil
} }
func (r *serverRepository) SetPinned(alias string, pinned bool) error {
for i, s := range servers {
if s.Alias == alias {
if pinned {
servers[i].PinnedAt = time.Now()
} else {
servers[i].PinnedAt = time.Time{}
}
return nil
}
}
return nil
}
func (r *serverRepository) RecordSSH(alias string) error {
for i, s := range servers {
if s.Alias == alias {
servers[i].LastSeen = time.Now()
return nil
}
}
return nil
}
+224 -23
View File
@@ -16,8 +16,11 @@ package ui
import ( import (
"fmt" "fmt"
"strings"
"time"
"github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/domain"
"github.com/atotto/clipboard"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
) )
@@ -34,7 +37,7 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
switch event.Rune() { switch event.Rune() {
case 'q': case 'q':
t.app.Stop() t.handleQuit()
return nil return nil
case '/': case '/':
t.handleSearchToggle() t.handleSearchToggle()
@@ -48,8 +51,26 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
case 'd': case 'd':
t.handleServerDelete() t.handleServerDelete()
return nil return nil
case '?': case 'p':
t.handleHelpShow() t.handleServerPin()
return nil
case 's':
t.handleSortToggle()
return nil
case 'S':
t.handleSortReverse()
return nil
case 'c':
t.handleCopyCommand()
return nil
case 'g':
t.handlePingSelected()
return nil
case 'r':
t.handleRefreshBackground()
return nil
case 't':
t.handleTagsEdit()
return nil return nil
} }
@@ -61,8 +82,52 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
return event return event
} }
func (t *tui) handleQuit() {
t.app.Stop()
}
func (t *tui) handleServerPin() {
if server, ok := t.serverList.GetSelectedServer(); ok {
pinned := server.PinnedAt.IsZero()
_ = t.serverService.SetPinned(server.Alias, pinned)
t.refreshServerList()
}
}
func (t *tui) handleSortToggle() {
t.sortMode = t.sortMode.ToggleField()
t.showStatusTemp("Sort: " + t.sortMode.String())
t.updateListTitle()
t.refreshServerList()
}
func (t *tui) handleSortReverse() {
t.sortMode = t.sortMode.Reverse()
t.showStatusTemp("Sort: " + t.sortMode.String())
t.updateListTitle()
t.refreshServerList()
}
func (t *tui) handleCopyCommand() {
if server, ok := t.serverList.GetSelectedServer(); ok {
cmd := BuildSSHCommand(server)
if err := clipboard.WriteAll(cmd); err == nil {
t.showStatusTemp("Copied: " + cmd)
} else {
t.showStatusTemp("Failed to copy to clipboard")
}
}
}
func (t *tui) handleTagsEdit() {
if server, ok := t.serverList.GetSelectedServer(); ok {
t.showEditTagsForm(server)
}
}
func (t *tui) handleSearchInput(query string) { func (t *tui) handleSearchInput(query string) {
filtered, _ := t.serverService.ListServers(query) filtered, _ := t.serverService.ListServers(query)
sortServersForUI(filtered, t.sortMode)
t.serverList.UpdateServers(filtered) t.serverList.UpdateServers(filtered)
if len(filtered) == 0 { if len(filtered) == 0 {
t.details.ShowEmpty() t.details.ShowEmpty()
@@ -100,12 +165,22 @@ func (t *tui) handleServerEdit() {
} }
func (t *tui) handleServerSave(server domain.Server, original *domain.Server) { func (t *tui) handleServerSave(server domain.Server, original *domain.Server) {
var err error
if original != nil { if original != nil {
// Edit mode // Edit mode
_ = t.serverService.UpdateServer(*original, server) err = t.serverService.UpdateServer(*original, server)
} else { } else {
// Add mode // Add mode
_ = t.serverService.AddServer(server) err = t.serverService.AddServer(server)
}
if err != nil {
// Stay on form; show a small modal with the error
modal := tview.NewModal().
SetText(fmt.Sprintf("Save failed: %v", err)).
AddButtons([]string{"Close"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) { t.handleModalClose() })
t.app.SetRoot(modal, true)
return
} }
t.refreshServerList() t.refreshServerList()
@@ -114,8 +189,7 @@ func (t *tui) handleServerSave(server domain.Server, original *domain.Server) {
func (t *tui) handleServerDelete() { func (t *tui) handleServerDelete() {
if server, ok := t.serverList.GetSelectedServer(); ok { if server, ok := t.serverList.GetSelectedServer(); ok {
_ = t.serverService.DeleteServer(server) t.showDeleteConfirmModal(server)
t.refreshServerList()
} }
} }
@@ -123,14 +197,66 @@ func (t *tui) handleFormCancel() {
t.returnToMain() t.returnToMain()
} }
func (t *tui) handleHelpShow() { func (t *tui) handlePingSelected() {
t.showHelpModal() if server, ok := t.serverList.GetSelectedServer(); ok {
alias := server.Alias
t.showStatusTemp(fmt.Sprintf("Pinging %s…", alias))
go func() {
up, dur, err := t.serverService.Ping(server)
t.app.QueueUpdateDraw(func() {
if err != nil {
t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN (%v)", alias, err), "#FF6B6B")
return
}
if up {
t.showStatusTempColor(fmt.Sprintf("Ping %s: UP (%s)", alias, dur), "#A0FFA0")
} else {
t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN", alias), "#FF6B6B")
}
})
}()
}
} }
func (t *tui) handleModalClose() { func (t *tui) handleModalClose() {
t.returnToMain() t.returnToMain()
} }
// handleRefreshBackground refreshes the server list in the background without leaving the current screen.
// It preserves the current search query and selection, shows transient status, and avoids concurrent runs.
func (t *tui) handleRefreshBackground() {
currentIdx := t.serverList.GetCurrentItem()
query := ""
if t.searchVisible {
query = t.searchBar.InputField.GetText()
}
t.showStatusTemp("Refreshing…")
go func(prevIdx int, q string) {
servers, err := t.serverService.ListServers(q)
if err != nil {
t.app.QueueUpdateDraw(func() {
t.showStatusTempColor(fmt.Sprintf("Refresh failed: %v", err), "#FF6B6B")
})
return
}
sortServersForUI(servers, t.sortMode)
t.app.QueueUpdateDraw(func() {
t.serverList.UpdateServers(servers)
// Try to restore selection if still valid
if prevIdx >= 0 && prevIdx < t.serverList.List.GetItemCount() {
t.serverList.SetCurrentItem(prevIdx)
if srv, ok := t.serverList.GetSelectedServer(); ok {
t.details.UpdateServer(srv)
}
}
t.showStatusTemp(fmt.Sprintf("Refreshed %d servers", len(servers)))
})
}(currentIdx, query)
}
// ============================================================================= // =============================================================================
// UI Display Functions (show UI elements/modals) // UI Display Functions (show UI elements/modals)
// ============================================================================= // =============================================================================
@@ -144,40 +270,89 @@ func (t *tui) showSearchBar() {
} }
func (t *tui) showConnectModal(server domain.Server) { func (t *tui) showConnectModal(server domain.Server) {
msg := fmt.Sprintf("SSH to %s (%s@%s:%d)\n\nThis is a mock action.", msg := fmt.Sprintf("SSH to %s (%s@%s:%d)\n\nConfirm to start an SSH session .",
server.Alias, server.User, server.Host, server.Port) server.Alias, server.User, server.Host, server.Port)
modal := tview.NewModal(). modal := tview.NewModal().
SetText(msg). SetText(msg).
AddButtons([]string{"OK"}). AddButtons([]string{"Confirm", "Cancel"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) { SetDoneFunc(func(buttonIndex int, buttonLabel string) {
if buttonIndex == 0 {
// Suspend the TUI while running the external ssh command.
t.app.Suspend(func() {
err := t.serverService.SSH(server.Alias)
if err != nil {
// Show a brief status after we resume
t.app.QueueUpdateDraw(func() {
if strings.Contains(strings.ToLower(err.Error()), "timeout") {
t.showStatusTempColor("SSH timeout, returning to list", "#FF6B6B")
} else {
t.showStatusTempColor("SSH failed: "+err.Error(), "#FF6B6B")
}
})
}
})
// Refresh to reflect updated last seen and ssh count
t.refreshServerList()
}
t.handleModalClose() t.handleModalClose()
}) })
t.app.SetRoot(modal, true) t.app.SetRoot(modal, true)
} }
func (t *tui) showHelpModal() { func (t *tui) showDeleteConfirmModal(server domain.Server) {
text := "Keyboard shortcuts:\n\n" + msg := fmt.Sprintf("Delete server %s (%s@%s:%d)?\n\nThis action cannot be undone.",
" ↑/↓ Navigate\n" + server.Alias, server.User, server.Host, server.Port)
" Enter SSH connect (mock)\n" +
" a Add server (mock)\n" +
" e Edit server (mock)\n" +
" d Delete entry (mock)\n" +
" / Focus search\n" +
" q Quit\n" +
" ? Help\n"
modal := tview.NewModal(). modal := tview.NewModal().
SetText(text). SetText(msg).
AddButtons([]string{"Close"}). AddButtons([]string{"Cancel", "Confirm"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) { SetDoneFunc(func(buttonIndex int, buttonLabel string) {
if buttonIndex == 1 {
_ = t.serverService.DeleteServer(server)
t.refreshServerList()
}
t.handleModalClose() t.handleModalClose()
}) })
t.app.SetRoot(modal, true) t.app.SetRoot(modal, true)
} }
func (t *tui) showEditTagsForm(server domain.Server) {
form := tview.NewForm()
form.SetBorder(true).
SetTitle(fmt.Sprintf("Edit Tags: %s", server.Alias)).
SetTitleAlign(tview.AlignLeft)
defaultTags := strings.Join(server.Tags, ", ")
form.AddInputField("Tags (comma):", defaultTags, 40, nil, nil)
form.AddButton("Save", func() {
text := strings.TrimSpace(form.GetFormItem(0).(*tview.InputField).GetText())
var tags []string
if text != "" {
for _, part := range strings.Split(text, ",") {
if s := strings.TrimSpace(part); s != "" {
tags = append(tags, s)
}
}
}
newServer := server
newServer.Tags = tags
_ = t.serverService.UpdateServer(server, newServer)
// Refresh UI and go back
t.refreshServerList()
t.returnToMain()
t.showStatusTemp("Tags updated")
})
form.AddButton("Cancel", func() { t.returnToMain() })
form.SetCancelFunc(func() { t.returnToMain() })
t.app.SetRoot(form, true)
t.app.SetFocus(form)
}
// ============================================================================= // =============================================================================
// UI State Management (hide UI elements) // UI State Management (hide UI elements)
// ============================================================================= // =============================================================================
@@ -200,9 +375,35 @@ func (t *tui) refreshServerList() {
query = t.searchBar.InputField.GetText() query = t.searchBar.InputField.GetText()
} }
filtered, _ := t.serverService.ListServers(query) filtered, _ := t.serverService.ListServers(query)
sortServersForUI(filtered, t.sortMode)
t.serverList.UpdateServers(filtered) t.serverList.UpdateServers(filtered)
} }
func (t *tui) returnToMain() { func (t *tui) returnToMain() {
t.app.SetRoot(t.root, true) t.app.SetRoot(t.root, true)
} }
// showStatusTemp displays a temporary message in the status bar (default green) and then restores the default text.
func (t *tui) showStatusTemp(msg string) {
if t.statusBar == nil {
return
}
t.showStatusTempColor(msg, "#A0FFA0")
}
// showStatusTempColor displays a temporary colored message in the status bar and restores default text after 2s.
func (t *tui) showStatusTempColor(msg string, color string) {
if t.statusBar == nil {
return
}
t.statusBar.SetText("[" + color + "]" + msg + "[-]")
time.AfterFunc(2*time.Second, func() {
if t.app != nil {
t.app.QueueUpdateDraw(func() {
if t.statusBar != nil {
t.statusBar.SetText(DefaultStatusText())
}
})
}
})
}
+1 -1
View File
@@ -22,6 +22,6 @@ import (
func NewHintBar() *tview.TextView { func NewHintBar() *tview.TextView {
hint := tview.NewTextView().SetDynamicColors(true) hint := tview.NewTextView().SetDynamicColors(true)
hint.SetBackgroundColor(tcell.Color233) hint.SetBackgroundColor(tcell.Color233)
hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • a Add • e Edit • d Delete • ? Help[-]") 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[-]")
return hint return hint
} }
+29 -3
View File
@@ -16,6 +16,7 @@ package ui
import ( import (
"fmt" "fmt"
"strings"
"github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/domain"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
@@ -43,12 +44,37 @@ func (sd *ServerDetails) build() {
SetTitleColor(tcell.Color250) SetTitleColor(tcell.Color250)
} }
// renderTagChips builds colored tag chips for details view.
func renderTagChips(tags []string) string {
if len(tags) == 0 {
return "-"
}
chips := make([]string, 0, len(tags))
for _, t := range tags {
chips = append(chips, fmt.Sprintf("[black:#5FAFFF] %s [-:-:-]", t))
}
return strings.Join(chips, " ")
}
func (sd *ServerDetails) UpdateServer(server domain.Server) { func (sd *ServerDetails) UpdateServer(server domain.Server) {
lastSeen := server.LastSeen.Format("2006-01-02 15:04:05")
if server.LastSeen.IsZero() {
lastSeen = "Never"
}
serverKey := server.Key
if serverKey == "" {
serverKey = "(default: ~/.ssh/id_{rsa,ed25519,ecdsa})"
}
pinnedStr := "true"
if server.PinnedAt.IsZero() {
pinnedStr = "false"
}
tagsText := renderTagChips(server.Tags)
text := fmt.Sprintf( text := fmt.Sprintf(
"[::b]%s[-]\n\nHost: [white]%s[-]\nUser: [white]%s[-]\nPort: [white]%d[-]\nKey: [white]%s[-]\nTags: [white]%s[-]\nStatus: %s\nLast: %s\n\n[::b]Commands:[-]\n Enter: SSH connect\n a: Add new server\n e: Edit entry\n d: Delete entry", "[::b]%s[-]\n\nHost: [white]%s[-]\nUser: [white]%s[-]\nPort: [white]%d[-]\nKey: [white]%s[-]\nTags: %s\nPinned: [white]%s[-]\nLast SSH: %s\nSSH Count: [white]%d[-]\n\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",
server.Alias, server.Host, server.User, server.Port, server.Alias, server.Host, server.User, server.Port,
server.Key, joinTags(server.Tags), statusIcon(server.Status), serverKey, tagsText, pinnedStr,
server.LastSeen.Format("2006-01-02 15:04")) lastSeen, server.SSHCount)
sd.TextView.SetText(text) sd.TextView.SetText(text)
} }
+84 -47
View File
@@ -16,9 +16,10 @@ package ui
import ( import (
"fmt" "fmt"
"net"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/domain"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
@@ -51,10 +52,7 @@ func NewServerForm(mode ServerFormMode, original *domain.Server) *ServerForm {
} }
func (sf *ServerForm) build() { func (sf *ServerForm) build() {
title := "Add Server" title := sf.titleForMode()
if sf.mode == ServerFormEdit {
title = "Edit Server"
}
sf.Form.SetBorder(true). sf.Form.SetBorder(true).
SetTitle(title). SetTitle(title).
@@ -69,24 +67,29 @@ func (sf *ServerForm) build() {
sf.Form.SetCancelFunc(sf.handleCancel) sf.Form.SetCancelFunc(sf.handleCancel)
} }
func (sf *ServerForm) titleForMode() string {
if sf.mode == ServerFormEdit {
return "Edit Server"
}
return "Add Server"
}
func (sf *ServerForm) addFormFields() { func (sf *ServerForm) addFormFields() {
var defaultValues ServerFormData var defaultValues ServerFormData
if sf.mode == ServerFormEdit && sf.original != nil { if sf.mode == ServerFormEdit && sf.original != nil {
defaultValues = ServerFormData{ defaultValues = ServerFormData{
Alias: sf.original.Alias, Alias: sf.original.Alias,
Host: sf.original.Host, Host: sf.original.Host,
User: sf.original.User, User: sf.original.User,
Port: fmt.Sprint(sf.original.Port), Port: fmt.Sprint(sf.original.Port),
Key: sf.original.Key, Key: sf.original.Key,
Tags: strings.Join(sf.original.Tags, ", "), Tags: strings.Join(sf.original.Tags, ", "),
Status: sf.original.Status,
} }
} else { } else {
defaultValues = ServerFormData{ defaultValues = ServerFormData{
User: "root", User: "root",
Port: "22", Port: "22",
Key: "~/.ssh/id_ed25519", Key: "~/.ssh/id_ed25519",
Status: "online",
} }
} }
@@ -96,28 +99,15 @@ func (sf *ServerForm) addFormFields() {
sf.Form.AddInputField("Port:", defaultValues.Port, 20, nil, nil) sf.Form.AddInputField("Port:", defaultValues.Port, 20, nil, nil)
sf.Form.AddInputField("Key:", defaultValues.Key, 40, nil, nil) sf.Form.AddInputField("Key:", defaultValues.Key, 40, nil, nil)
sf.Form.AddInputField("Tags (comma):", defaultValues.Tags, 30, nil, nil) sf.Form.AddInputField("Tags (comma):", defaultValues.Tags, 30, nil, nil)
statusDD := tview.NewDropDown().SetLabel("Status: ")
statusOptions := []string{"online", "warn", "offline"}
statusDD.SetOptions(statusOptions, nil)
for i, opt := range statusOptions {
if opt == defaultValues.Status {
statusDD.SetCurrentOption(i)
break
}
}
sf.Form.AddFormItem(statusDD)
} }
type ServerFormData struct { type ServerFormData struct {
Alias string Alias string
Host string Host string
User string User string
Port string Port string
Key string Key string
Tags string Tags string
Status string
} }
func (sf *ServerForm) getFormData() ServerFormData { func (sf *ServerForm) getFormData() ServerFormData {
@@ -133,14 +123,18 @@ func (sf *ServerForm) getFormData() ServerFormData {
func (sf *ServerForm) handleSave() { func (sf *ServerForm) handleSave() {
data := sf.getFormData() data := sf.getFormData()
if data.Alias == "" || data.Host == "" {
if errMsg := validateServerForm(data); errMsg != "" {
sf.Form.SetTitle(fmt.Sprintf("%s — [red::b]%s[-]", sf.titleForMode(), errMsg))
sf.Form.SetBorderColor(tcell.ColorRed)
return return
} }
sf.Form.SetTitle(sf.titleForMode())
sf.Form.SetBorderColor(tcell.Color238)
server := sf.dataToServer(data) server := sf.dataToServer(data)
if sf.original == nil {
server.LastSeen = time.Now()
}
if sf.onSave != nil { if sf.onSave != nil {
sf.onSave(server, sf.original) sf.onSave(server, sf.original)
} }
@@ -169,18 +163,61 @@ func (sf *ServerForm) dataToServer(data ServerFormData) domain.Server {
} }
} }
_, status := sf.Form.GetFormItem(6).(*tview.DropDown).GetCurrentOption()
return domain.Server{ return domain.Server{
Alias: data.Alias, Alias: data.Alias,
Host: data.Host, Host: data.Host,
User: data.User, User: data.User,
Port: port, Port: port,
Key: data.Key, Key: data.Key,
Tags: tags, Tags: tags,
Status: status,
} }
} }
// validateServerForm returns an error message string if validation fails; empty string means valid.
func validateServerForm(data ServerFormData) string {
alias := data.Alias
if alias == "" {
return "Alias is required"
}
if !regexp.MustCompile(`^[A-Za-z0-9_.-]+$`).MatchString(alias) {
return "Alias may contain letters, digits, dot, dash, underscore"
}
host := data.Host
if host == "" {
return "Host/IP is required"
}
if ip := net.ParseIP(host); ip == nil {
if strings.Contains(host, " ") {
return "Host must not contain spaces"
}
if !regexp.MustCompile(`^[A-Za-z0-9.-]+$`).MatchString(host) {
return "Host contains invalid characters"
}
if strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") {
return "Host must not start or end with a dot"
}
for _, lbl := range strings.Split(host, ".") {
if lbl == "" {
return "Host must not contain empty labels"
}
if strings.HasPrefix(lbl, "-") || strings.HasSuffix(lbl, "-") {
return "Hostname labels must not start or end with a hyphen"
}
}
}
if data.Port != "" {
p, err := strconv.Atoi(data.Port)
if err != nil || p < 1 || p > 65535 {
return "Port must be a number between 1 and 65535"
}
}
return ""
}
func (sf *ServerForm) OnSave(fn func(domain.Server, *domain.Server)) *ServerForm { func (sf *ServerForm) OnSave(fn func(domain.Server, *domain.Server)) *ServerForm {
sf.onSave = fn sf.onSave = fn
return sf return sf
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
import (
"sort"
"strings"
"github.com/Adembc/lazyssh/internal/core/domain"
)
// SortMode controls how unpinned servers are ordered in the UI.
type SortMode int
const (
SortByAliasAsc SortMode = iota
SortByAliasDesc
SortByLastSeenDesc
SortByLastSeenAsc
)
func (m SortMode) String() string {
switch m {
case SortByAliasAsc:
return "Alias ↑"
case SortByAliasDesc:
return "Alias ↓"
case SortByLastSeenAsc:
return "Last SSH ↑"
case SortByLastSeenDesc:
return "Last SSH ↓"
default:
return "Alias ↑"
}
}
// ToggleField switches between Alias and LastSeen while preserving direction.
func (m SortMode) ToggleField() SortMode {
switch m {
case SortByAliasAsc:
return SortByLastSeenAsc
case SortByAliasDesc:
return SortByLastSeenDesc
case SortByLastSeenAsc:
return SortByAliasAsc
case SortByLastSeenDesc:
return SortByAliasDesc
default:
return SortByAliasAsc
}
}
// Reverse flips the direction within the current field.
func (m SortMode) Reverse() SortMode {
switch m {
case SortByAliasAsc:
return SortByAliasDesc
case SortByAliasDesc:
return SortByAliasAsc
case SortByLastSeenAsc:
return SortByLastSeenDesc
case SortByLastSeenDesc:
return SortByLastSeenAsc
default:
return SortByAliasAsc
}
}
// sortServersForUI sorts servers according to the rules required by the UI.
// Pinned servers are always at the top, ordered by pinned date (newest first).
// Unpinned servers are sorted by the selected mode. "Never" (zero time) goes to
// the bottom when sorting by last seen asc/desc accordingly. Ties break by Alias asc.
func sortServersForUI(servers []domain.Server, mode SortMode) {
sort.SliceStable(servers, func(i, j int) bool {
si, sj := servers[i], servers[j]
pi, pj := !si.PinnedAt.IsZero(), !sj.PinnedAt.IsZero()
if pi != pj {
return pi
}
if pi && pj { // both pinned: newer pinned first, tie-break alias
if !si.PinnedAt.Equal(sj.PinnedAt) {
return si.PinnedAt.After(sj.PinnedAt)
}
return strings.ToLower(si.Alias) < strings.ToLower(sj.Alias)
}
// both unpinned
switch mode {
case SortByLastSeenDesc, SortByLastSeenAsc:
zi := si.LastSeen.IsZero()
zj := sj.LastSeen.IsZero()
if zi != zj {
// when sorting by last seen, entries with zero (never) should be bottom in either direction
return !zi // non-zero first
}
if !zi && !zj && !si.LastSeen.Equal(sj.LastSeen) {
if mode == SortByLastSeenDesc {
return si.LastSeen.After(sj.LastSeen)
}
return si.LastSeen.Before(sj.LastSeen)
}
// tie-break by alias asc
return strings.ToLower(si.Alias) < strings.ToLower(sj.Alias)
case SortByAliasAsc:
return strings.ToLower(si.Alias) < strings.ToLower(sj.Alias)
case SortByAliasDesc:
ai := strings.ToLower(si.Alias)
aj := strings.ToLower(sj.Alias)
if ai != aj {
return ai > aj
}
return false
default:
return strings.ToLower(si.Alias) < strings.ToLower(sj.Alias)
}
})
}
+5 -1
View File
@@ -19,10 +19,14 @@ import (
"github.com/rivo/tview" "github.com/rivo/tview"
) )
func DefaultStatusText() string {
return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit"
}
func NewStatusBar() *tview.TextView { func NewStatusBar() *tview.TextView {
status := tview.NewTextView().SetDynamicColors(true) status := tview.NewTextView().SetDynamicColors(true)
status.SetBackgroundColor(tcell.Color235) status.SetBackgroundColor(tcell.Color235)
status.SetTextAlign(tview.AlignCenter) status.SetTextAlign(tview.AlignCenter)
status.SetText("[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]a[-] Add • [white]e[-] Edit • [white]d[-] Delete • [white]/[-] Search • [white]q[-] Quit • [white]?[-] Help") status.SetText(DefaultStatusText())
return status return status
} }
+31 -4
View File
@@ -17,12 +17,16 @@ package ui
import ( import (
"time" "time"
"github.com/Adembc/lazyssh/internal/core/ports"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"go.uber.org/zap"
"github.com/Adembc/lazyssh/internal/core/ports"
"github.com/rivo/tview" "github.com/rivo/tview"
) )
type tui struct { type tui struct {
logger *zap.SugaredLogger
version string version string
commit string commit string
buildDate string buildDate string
@@ -41,11 +45,13 @@ type tui struct {
left *tview.Flex left *tview.Flex
content *tview.Flex content *tview.Flex
sortMode SortMode
searchVisible bool searchVisible bool
} }
func NewTUI(ss ports.ServerService, version, commit, buildDate string) *tui { func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit, buildDate string) *tui {
return &tui{ return &tui{
logger: logger,
app: tview.NewApplication(), app: tview.NewApplication(),
serverService: ss, serverService: ss,
version: version, version: version,
@@ -55,10 +61,19 @@ func NewTUI(ss ports.ServerService, version, commit, buildDate string) *tui {
} }
func (t *tui) Run() error { func (t *tui) Run() error {
defer func() {
if r := recover(); r != nil {
t.logger.Errorw("panic recovered", "error", r)
}
}()
t.app.EnableMouse(true) t.app.EnableMouse(true)
t.initializeTheme().buildComponents().buildLayout().bindEvents().loadInitialData().loadSplashScreen() t.initializeTheme().buildComponents().buildLayout().bindEvents().loadInitialData().loadSplashScreen()
t.logger.Infow("starting TUI application", "version", t.version, "commit", t.commit, "buildDate", t.buildDate)
return t.app.Run() if err := t.app.Run(); err != nil {
t.logger.Errorw("application run error", "error", err)
return err
}
return nil
} }
func (t *tui) initializeTheme() *tui { func (t *tui) initializeTheme() *tui {
@@ -83,6 +98,10 @@ func (t *tui) buildComponents() *tui {
OnSelectionChange(t.handleServerSelectionChange) OnSelectionChange(t.handleServerSelectionChange)
t.details = NewServerDetails() t.details = NewServerDetails()
t.statusBar = NewStatusBar() t.statusBar = NewStatusBar()
// default sort mode
t.sortMode = SortByAliasAsc
return t return t
} }
@@ -112,11 +131,19 @@ func (t *tui) bindEvents() *tui {
func (t *tui) loadInitialData() *tui { func (t *tui) loadInitialData() *tui {
servers, _ := t.serverService.ListServers("") servers, _ := t.serverService.ListServers("")
sortServersForUI(servers, t.sortMode)
t.updateListTitle()
t.serverList.UpdateServers(servers) t.serverList.UpdateServers(servers)
return t return t
} }
func (t *tui) updateListTitle() {
if t.serverList != nil {
t.serverList.SetTitle("Servers — Sort: " + t.sortMode.String())
}
}
func (t *tui) loadSplashScreen() *tui { func (t *tui) loadSplashScreen() *tui {
splash, stop := buildSplash(t.app) splash, stop := buildSplash(t.app)
t.app.SetRoot(splash, true) t.app.SetRoot(splash, true)
+99 -32
View File
@@ -20,53 +20,120 @@ import (
"time" "time"
"github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/domain"
"github.com/mattn/go-runewidth"
) )
func statusIcon(s string) string { // renderTagBadgesForList renders up to two colored tag chips for the server list.
switch s { // If there are more tags, it appends a subtle gray "+N" badge. Returns an empty
case "online": // string when there are no tags to avoid cluttering the list.
return "🟢" func renderTagBadgesForList(tags []string) string {
case "warn": if len(tags) == 0 {
return "🟡" return ""
case "offline":
return "🔴"
default:
return "⚪"
} }
maxTags := 2
shown := tags
if len(tags) > maxTags {
shown = tags[:maxTags]
}
parts := make([]string, 0, len(shown)+1)
for _, t := range shown {
// Light blue background chip, similar to details view.
parts = append(parts, fmt.Sprintf("[black:#5FAFFF] %s [-:-:-]", t))
}
if extra := len(tags) - len(shown); extra > 0 {
parts = append(parts, fmt.Sprintf("[#8A8A8A]+%d[-]", extra))
}
return strings.Join(parts, " ")
} }
func joinTags(tags []string) string { // cellPad pads a string with spaces so its display width is at least `width` cells.
if len(tags) == 0 { // This keeps emoji-based icons from breaking alignment in tview.
return "-" func cellPad(s string, width int) string {
w := runewidth.StringWidth(s)
if w >= width {
return s
} }
return strings.Join(tags, ",") return s + strings.Repeat(" ", width-w)
}
func pinnedIcon(pinnedAt time.Time) string {
// Use emojis for a nicer UI; combined with cellPad to keep widths consistent in tview.
if pinnedAt.IsZero() {
return "📡" // not pinned
}
return "📌" // pinned
} }
func formatServerLine(s domain.Server) (primary, secondary string) { func formatServerLine(s domain.Server) (primary, secondary string) {
icon := statusIcon(s.Status) icon := cellPad(pinnedIcon(s.PinnedAt), 2)
// Choose a color per status for the alias and a subtle gray for host/time // Use a consistent color for alias; the icon reflects pinning
statusColor := "white" primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last SSH: %s[-] %s", icon, s.Alias, s.Host, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags))
switch s.Status {
case "online":
statusColor = "green"
case "warn":
statusColor = "yellow"
case "offline":
statusColor = "red"
}
primary = fmt.Sprintf("%s [%s::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last:%s[-]", icon, statusColor, s.Alias, s.Host, humanizeDuration(time.Since(s.LastSeen)))
secondary = "" secondary = ""
return return
} }
func humanizeDuration(d time.Duration) string { func humanizeDuration(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
if d < time.Minute { if d < time.Minute {
return "just now" return "just now"
} }
h := int(d.Hours()) if d < time.Hour {
m := int(d.Minutes()) % 60 m := int(d.Minutes())
if h > 0 { return fmt.Sprintf("%dm ago", m)
return fmt.Sprintf("%dh%dm ago", h, m)
} }
return fmt.Sprintf("%dm ago", m) if d < 48*time.Hour {
h := int(d.Hours())
return fmt.Sprintf("%dh ago", h)
}
if d < 60*24*time.Hour {
days := int(d.Hours()) / 24
return fmt.Sprintf("%dd ago", days)
}
if d < 365*24*time.Hour {
months := int(d.Hours()) / (24 * 30)
if months < 1 {
months = 1
}
return fmt.Sprintf("%dmo ago", months)
}
years := int(d.Hours()) / (24 * 365)
if years < 1 {
years = 1
}
return fmt.Sprintf("%dy ago", years)
}
// BuildSSHCommand constructs a ready-to-run ssh command for the given server.
// Format: ssh [user@]host [-p PORT if not 22] [-i KEY if provided]
func BuildSSHCommand(s domain.Server) string {
parts := []string{"ssh"}
userHost := ""
switch {
case s.User != "" && s.Host != "":
userHost = fmt.Sprintf("%s@%s", s.User, s.Host)
case s.Host != "":
userHost = s.Host
default:
userHost = s.Alias
}
parts = append(parts, userHost)
if s.Port != 0 && s.Port != 22 {
parts = append(parts, "-p", fmt.Sprintf("%d", s.Port))
}
if s.Key != "" {
parts = append(parts, "-i", quoteIfNeeded(s.Key))
}
return strings.Join(parts, " ")
}
// quoteIfNeeded returns the value quoted if it contains spaces.
func quoteIfNeeded(val string) string {
if strings.ContainsAny(val, " \t") {
return fmt.Sprintf("%q", val)
}
return val
} }
+2 -1
View File
@@ -23,6 +23,7 @@ type Server struct {
Port int Port int
Key string Key string
Tags []string Tags []string
Status string
LastSeen time.Time LastSeen time.Time
PinnedAt time.Time
SSHCount int
} }
+2
View File
@@ -21,4 +21,6 @@ type ServerRepository interface {
UpdateServer(server domain.Server, newServer domain.Server) error UpdateServer(server domain.Server, newServer domain.Server) error
AddServer(server domain.Server) error AddServer(server domain.Server) error
DeleteServer(server domain.Server) error DeleteServer(server domain.Server) error
SetPinned(alias string, pinned bool) error
RecordSSH(alias string) error
} }
+8 -1
View File
@@ -14,11 +14,18 @@
package ports package ports
import "github.com/Adembc/lazyssh/internal/core/domain" import (
"time"
"github.com/Adembc/lazyssh/internal/core/domain"
)
type ServerService interface { type ServerService interface {
ListServers(query string) ([]domain.Server, error) ListServers(query string) ([]domain.Server, error)
UpdateServer(server domain.Server, newServer domain.Server) error UpdateServer(server domain.Server, newServer domain.Server) error
AddServer(server domain.Server) error AddServer(server domain.Server) error
DeleteServer(server domain.Server) error DeleteServer(server domain.Server) error
SetPinned(alias string, pinned bool) error
SSH(alias string) error
Ping(server domain.Server) (bool, time.Duration, error)
} }
+188 -6
View File
@@ -15,42 +15,224 @@
package services package services
import ( import (
"bufio"
"fmt"
"net"
"os"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/domain"
"github.com/Adembc/lazyssh/internal/core/ports" "github.com/Adembc/lazyssh/internal/core/ports"
"go.uber.org/zap"
) )
type serverService struct { type serverService struct {
serverRepository ports.ServerRepository serverRepository ports.ServerRepository
logger *zap.SugaredLogger
} }
// NewServerService creates a new instance of serverService. // NewServerService creates a new instance of serverService.
func NewServerService(sr ports.ServerRepository) *serverService { func NewServerService(logger *zap.SugaredLogger, sr ports.ServerRepository) *serverService {
return &serverService{ return &serverService{
logger: logger,
serverRepository: sr, serverRepository: sr,
} }
} }
// ListServers returns a list of servers. // ListServers returns a list of servers sorted with pinned on top.
func (s *serverService) ListServers(query string) ([]domain.Server, error) { func (s *serverService) ListServers(query string) ([]domain.Server, error) {
// do any relevant business logic here if needed
servers, err := s.serverRepository.ListServers(query) servers, err := s.serverRepository.ListServers(query)
if err != nil { if err != nil {
s.logger.Errorw("failed to list servers", "error", err)
return nil, err return nil, err
} }
// Sort: pinned first (PinnedAt non-zero), then by PinnedAt desc, then by Alias asc.
sort.SliceStable(servers, func(i, j int) bool {
pi := !servers[i].PinnedAt.IsZero()
pj := !servers[j].PinnedAt.IsZero()
if pi != pj {
return pi
}
if pi && pj {
return servers[i].PinnedAt.After(servers[j].PinnedAt)
}
return servers[i].Alias < servers[j].Alias
})
return servers, nil return servers, nil
} }
// validateServer performs core validation of server fields.
func validateServer(srv domain.Server) error {
if strings.TrimSpace(srv.Alias) == "" {
return fmt.Errorf("alias is required")
}
if ok, _ := regexp.MatchString(`^[A-Za-z0-9_.-]+$`, srv.Alias); !ok {
return fmt.Errorf("alias may contain letters, digits, dot, dash, underscore")
}
if strings.TrimSpace(srv.Host) == "" {
return fmt.Errorf("Host/IP is required")
}
if ip := net.ParseIP(srv.Host); ip == nil {
if strings.Contains(srv.Host, " ") {
return fmt.Errorf("host must not contain spaces")
}
if ok, _ := regexp.MatchString(`^[A-Za-z0-9.-]+$`, srv.Host); !ok {
return fmt.Errorf("host contains invalid characters")
}
if strings.HasPrefix(srv.Host, ".") || strings.HasSuffix(srv.Host, ".") {
return fmt.Errorf("host must not start or end with a dot")
}
for _, lbl := range strings.Split(srv.Host, ".") {
if lbl == "" {
return fmt.Errorf("host must not contain empty labels")
}
if strings.HasPrefix(lbl, "-") || strings.HasSuffix(lbl, "-") {
return fmt.Errorf("hostname labels must not start or end with a hyphen")
}
}
}
if srv.Port != 0 && (srv.Port < 1 || srv.Port > 65535) {
return fmt.Errorf("port must be a number between 1 and 65535")
}
return nil
}
// UpdateServer updates an existing server with new details. // UpdateServer updates an existing server with new details.
func (s *serverService) UpdateServer(server domain.Server, newServer domain.Server) error { func (s *serverService) UpdateServer(server domain.Server, newServer domain.Server) error {
return s.serverRepository.UpdateServer(server, newServer) if err := validateServer(newServer); err != nil {
s.logger.Warnw("validation failed on update", "error", err, "server", newServer)
return err
}
err := s.serverRepository.UpdateServer(server, newServer)
if err != nil {
s.logger.Errorw("failed to update server", "error", err, "server", server)
}
return err
} }
// AddServer adds a new server to the repository. // AddServer adds a new server to the repository.
func (s *serverService) AddServer(server domain.Server) error { func (s *serverService) AddServer(server domain.Server) error {
return s.serverRepository.AddServer(server) if err := validateServer(server); err != nil {
s.logger.Warnw("validation failed on add", "error", err, "server", server)
return err
}
err := s.serverRepository.AddServer(server)
if err != nil {
s.logger.Errorw("failed to add server", "error", err, "server", server)
}
return err
} }
// DeleteServer removes a server from the repository. // DeleteServer removes a server from the repository.
func (s *serverService) DeleteServer(server domain.Server) error { func (s *serverService) DeleteServer(server domain.Server) error {
return s.serverRepository.DeleteServer(server) err := s.serverRepository.DeleteServer(server)
if err != nil {
s.logger.Errorw("failed to delete server", "error", err, "server", server)
}
return err
}
// SetPinned sets or clears a pin timestamp for the server alias.
func (s *serverService) SetPinned(alias string, pinned bool) error {
err := s.serverRepository.SetPinned(alias, pinned)
if err != nil {
s.logger.Errorw("failed to set pin state", "error", err, "alias", alias, "pinned", pinned)
}
return err
}
// SSH starts an interactive SSH session to the given alias using the system's ssh client.
func (s *serverService) SSH(alias string) error {
s.logger.Infow("ssh start", "alias", alias)
cmd := exec.Command("ssh", alias)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
s.logger.Errorw("ssh command failed", "alias", alias, "error", err)
return err
}
if err := s.serverRepository.RecordSSH(alias); err != nil {
s.logger.Errorw("failed to record ssh metadata", "alias", alias, "error", err)
}
s.logger.Infow("ssh 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()
host, port, ok := resolveSSHDestination(server.Alias)
if !ok {
host = strings.TrimSpace(server.Host)
if host == "" {
host = server.Alias
}
if server.Port > 0 {
port = server.Port
} else {
port = 22
}
}
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
dialer := net.Dialer{Timeout: 3 * time.Second}
conn, err := dialer.Dial("tcp", addr)
if err != nil {
return false, time.Since(start), err
}
_ = conn.Close()
return true, time.Since(start), nil
}
// resolveSSHDestination uses `ssh -G <alias>` to extract HostName and Port from the user's SSH config.
// Returns host, port, ok where ok=false if resolution failed.
func resolveSSHDestination(alias string) (string, int, bool) {
alias = strings.TrimSpace(alias)
if alias == "" {
return "", 0, false
}
cmd := exec.Command("ssh", "-G", alias)
out, err := cmd.Output()
if err != nil {
return "", 0, false
}
host := ""
port := 0
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 host == "" {
host = alias
}
if port == 0 {
port = 22
}
return host, port, true
} }
+58
View File
@@ -0,0 +1,58 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package logger
import (
"os"
"path/filepath"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// New constructs a Sugared Logger that writes to a file and
// provides human-readable timestamps.
func New(service string, outputPaths ...string) (*zap.SugaredLogger, error) {
config := zap.NewProductionConfig()
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
config.DisableStacktrace = true
config.InitialFields = map[string]any{
"service": service,
}
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
logDir := filepath.Join(home, ".lazyssh")
if err := os.MkdirAll(logDir, 0o750); err != nil {
return nil, err
}
config.OutputPaths = []string{filepath.Join(logDir, "lazyssh.log")}
if outputPaths != nil {
config.OutputPaths = outputPaths
}
log, err := config.Build(zap.WithCaller(true))
if err != nil {
return nil, err
}
return log.Sugar(), nil
}
+1 -1
View File
@@ -107,7 +107,7 @@ check: staticcheck ## Run staticcheck analyzer
$(STATICCHECK) ./... $(STATICCHECK) ./...
.PHONY: quality .PHONY: quality
quality: fmt vet ## Run all code quality checks (i will add lint and check) quality: fmt vet lint ## Run all code quality checks
##@ Testing ##@ Testing