first commit

This commit is contained in:
Gu1llaum-3 2025-08-31 22:57:23 +02:00
commit fad2585d5e
19 changed files with 2783 additions and 0 deletions

104
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,104 @@
name: Build Binaries
on:
push:
tags:
- '*'
release:
types: [created]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
include:
# Linux AMD64
- goos: linux
goarch: amd64
suffix: linux-amd64
# Linux ARM64
- goos: linux
goarch: arm64
suffix: linux-arm64
# macOS AMD64 (Intel)
- goos: darwin
goarch: amd64
suffix: darwin-amd64
# macOS ARM64 (Apple Silicon)
- goos: darwin
goarch: arm64
suffix: darwin-arm64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
mkdir -p dist
VERSION=${GITHUB_REF#refs/tags/}
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
- name: Create tarball
run: |
cd dist
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
rm sshm-${{ matrix.suffix }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: sshm-${{ matrix.suffix }}
path: dist/sshm-${{ matrix.suffix }}.tar.gz
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: ./artifacts
merge-multiple: true
- name: Prepare release assets
run: |
mkdir -p release
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
ls -la ./release/
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: ./release/*
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

64
.gitignore vendored Normal file
View File

@ -0,0 +1,64 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Build artifacts
dist/
build/
bin/
# Temporary files
*.tmp
*.temp
temp/
# Log files
*.log
# Project specific
sshm
sshm-*
!sshm-test
release/
# Backup files
*.backup
*.bak
# Local development files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Guillaume Archambault
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

293
README.md Normal file
View File

@ -0,0 +1,293 @@
<p align="center">
<img src="images/logo.png" alt="SSHM Logo" width="120" />
</p>
# 🚀 SSHM - SSH Manager
[![Go](https://img.shields.io/badge/Go-1.23+-00ADD8?style=for-the-badge&logo=go)](https://golang.org/)
[![Release](https://img.shields.io/github/v/release/Gu1llaum-3/sshm?style=for-the-badge)](https://github.com/Gu1llaum-3/sshm/releases)
[![License](https://img.shields.io/github/license/Gu1llaum-3/sshm?style=for-the-badge)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS-lightgrey?style=for-the-badge)](https://github.com/Gu1llaum-3/sshm/releases)
> **A modern, interactive SSH Manager for your terminal** 🔥
SSHM is a beautiful command-line tool that transforms how you manage and connect to your SSH hosts. Built with Go and featuring an intuitive TUI interface, it makes SSH connection management effortless and enjoyable.
<p align="center">
<img src="images/sshm.gif" alt="Demo SSHM Terminal" width="600" />
</p>
## ✨ Features
### 🎯 **Core Features**
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
- **⚡ Quick Connect** - Connect to any host instantly
- **📝 Easy Management** - Add, edit, and manage SSH configurations seamlessly
- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
### 🛠️ **Management Operations**
- **Add new SSH hosts** with interactive forms
- **Edit existing configurations** in-place
- **Delete hosts** with confirmation prompts
- **Backup configurations** automatically before changes
- **Validate settings** to prevent configuration errors
- **ProxyJump support** for secure connection tunneling through bastion hosts
- **SSH Options management** - Add any SSH option with automatic format conversion
- **Full SSH compatibility** - Maintains compatibility with standard SSH tools
### 🎮 **User Experience**
- **Zero configuration** - Works out of the box with your existing SSH setup
- **Keyboard shortcuts** for power users
- **Cross-platform** - Supports Linux and macOS (Intel & Apple Silicon)
- **Lightweight** - Single binary with no dependencies
## 🚀 Quick Start
### Installation
**One-line install (Recommended):**
```bash
curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash
```
**Alternative methods:**
```bash
# Download specific release
wget https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz
# Extract and install
tar -xzf sshm-linux-amd64.tar.gz
sudo mv sshm-linux-amd64 /usr/local/bin/sshm
```
## 📖 Usage
### Interactive Mode
Launch SSHM without arguments to enter the beautiful TUI interface:
```bash
sshm
```
**Navigation:**
- `↑/↓` or `j/k` - Navigate hosts
- `Enter` - Connect to selected host
- `a` - Add new host
- `e` - Edit selected host
- `d` - Delete selected host
- `q` - Quit
- `/` - Search/filter hosts
The interactive forms will guide you through configuration:
- **Hostname/IP** - Server address
- **Username** - SSH user
- **Port** - SSH port (default: 22)
- **Identity File** - Private key path
- **ProxyJump** - Jump server for connection tunneling
- **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`)
- **Tags** - Comma-separated tags for organization
### CLI Usage
SSHM provides both command-line operations and an interactive TUI interface:
```bash
# Launch interactive TUI mode for browsing and connecting to hosts
sshm
# Add a new host using interactive form
sshm add
# Add a new host with pre-filled hostname
sshm add hostname
# Edit an existing host configuration
sshm edit my-server
# Show version information
sshm --version
# Show help and available commands
sshm --help
```
## 🏗️ Configuration
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
**Example configuration:**
```ssh
# Tags: production, web, frontend
Host web-prod-01
HostName 192.168.1.10
User deploy
Port 22
IdentityFile ~/.ssh/production_key
Compression yes
ServerAliveInterval 60
# Tags: development, database
Host db-dev
HostName dev-db.company.com
User admin
Port 2222
IdentityFile ~/.ssh/dev_key
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
# Tags: production, backend
Host backend-prod
HostName 10.0.1.50
User app
Port 22
ProxyJump bastion.company.com
IdentityFile ~/.ssh/production_key
Compression yes
ServerAliveInterval 300
BatchMode yes
```
### Supported SSH Options
SSHM supports all standard SSH configuration options:
**Built-in Fields:**
- `HostName` - Server hostname or IP address
- `User` - Username for SSH connection
- `Port` - SSH port number
- `IdentityFile` - Path to private key file
- `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`)
- `Tags` - Custom tags (SSHM extension)
**Additional SSH Options:**
You can add any valid SSH option using the "SSH Options" field in the interactive forms. Enter them in command-line format (e.g., `-o Compression=yes -o ServerAliveInterval=60`) and SSHM will automatically convert them to the proper SSH config format.
**Common SSH Options:**
- `Compression` - Enable/disable compression (`yes`/`no`)
- `ServerAliveInterval` - Interval in seconds for keepalive messages
- `ServerAliveCountMax` - Maximum number of keepalive messages
- `StrictHostKeyChecking` - Host key verification (`yes`/`no`/`ask`)
- `UserKnownHostsFile` - Path to known hosts file
- `BatchMode` - Disable interactive prompts (`yes`/`no`)
- `ConnectTimeout` - Connection timeout in seconds
- `ControlMaster` - Connection multiplexing (`yes`/`no`/`auto`)
- `ControlPath` - Path for control socket
- `ControlPersist` - Keep connection alive duration
- `ForwardAgent` - Forward SSH agent (`yes`/`no`)
- `LocalForward` - Local port forwarding (e.g., `8080:localhost:80`)
- `RemoteForward` - Remote port forwarding
- `DynamicForward` - SOCKS proxy port forwarding
**Example usage in forms:**
```
SSH Options: -o Compression=yes -o ServerAliveInterval=60 -o StrictHostKeyChecking=no
```
This will be automatically converted to:
```ssh
Compression yes
ServerAliveInterval 60
StrictHostKeyChecking no
```
## 🛠️ Development
### Prerequisites
- Go 1.23+
- Git
### Build from Source
```bash
# Clone the repository
git clone https://github.com/Gu1llaum-3/sshm.git
cd sshm
# Build the binary
go build -o sshm .
# Run
./sshm
```
### Project Structure
```
sshm/
├── cmd/ # CLI commands (Cobra)
│ ├── root.go # Root command and interactive mode
│ ├── add.go # Add host command
│ └── edit.go # Edit host command
├── internal/
│ ├── config/ # SSH configuration management
│ │ └── ssh.go # Config parsing and manipulation
│ ├── ui/ # Terminal UI components
│ │ ├── tui.go # Main TUI interface
│ │ ├── add_form.go # Add host form
│ │ └── edit_form.go# Edit host form
│ └── validation/ # Input validation
│ └── ssh.go # SSH config validation
├── install/ # Installation scripts
│ ├── unix.sh # Unix/Linux/macOS installer
│ └── README.md # Installation guide
└── .github/workflows/ # CI/CD pipelines
└── build.yml # Multi-platform builds
```
### Dependencies
- [Cobra](https://github.com/spf13/cobra) - CLI framework
- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework
- [Bubbles](https://github.com/charmbracelet/bubbles) - TUI components
- [Lipgloss](https://github.com/charmbracelet/lipgloss) - Styling
## 📦 Releases
Automated releases are built for multiple platforms:
| Platform | Architecture | Download |
|----------|-------------|----------|
| Linux | AMD64 | [sshm-linux-amd64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz) |
| Linux | ARM64 | [sshm-linux-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-arm64.tar.gz) |
| macOS | Intel | [sshm-darwin-amd64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-amd64.tar.gz) |
| macOS | Apple Silicon | [sshm-darwin-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-arm64.tar.gz) |
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
### Development Workflow
1. **Fork** the repository
2. **Create** a feature branch (`git checkout -b feature/amazing-feature`)
3. **Commit** your changes (`git commit -m 'Add amazing feature'`)
4. **Push** to the branch (`git push origin feature/amazing-feature`)
5. **Open** a Pull Request
## 📝 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- [Charm](https://charm.sh/) for the amazing TUI libraries
- [Cobra](https://cobra.dev/) for the excellent CLI framework
- The Go community for building such fantastic tools
---
<div align="center">
**Made with ❤️ by [Guillaume](https://github.com/Gu1llaum-3)**
**Star this repo if you found it useful!**
</div>

30
cmd/add.go Normal file
View File

@ -0,0 +1,30 @@
package cmd
import (
"fmt"
"sshm/internal/ui"
"github.com/spf13/cobra"
)
var addCmd = &cobra.Command{
Use: "add [hostname]",
Short: "Add a new SSH host configuration",
Long: `Add a new SSH host configuration with an interactive form.`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var hostname string
if len(args) > 0 {
hostname = args[0]
}
err := ui.RunAddForm(hostname)
if err != nil {
fmt.Printf("Error adding host: %v\n", err)
}
},
}
func init() {
rootCmd.AddCommand(addCmd)
}

27
cmd/edit.go Normal file
View File

@ -0,0 +1,27 @@
package cmd
import (
"fmt"
"sshm/internal/ui"
"github.com/spf13/cobra"
)
var editCmd = &cobra.Command{
Use: "edit <hostname>",
Short: "Edit an existing SSH host configuration",
Long: `Edit an existing SSH host configuration with an interactive form.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
hostname := args[0]
err := ui.RunEditForm(hostname)
if err != nil {
fmt.Printf("Error editing host: %v\n", err)
}
},
}
func init() {
rootCmd.AddCommand(editCmd)
}

107
cmd/root.go Normal file
View File

@ -0,0 +1,107 @@
package cmd
import (
"fmt"
"log"
"os"
"sshm/internal/config"
"sshm/internal/ui"
"github.com/spf13/cobra"
)
// version will be set at build time via -ldflags
var version = "dev"
var rootCmd = &cobra.Command{
Use: "sshm",
Short: "SSH Manager - A modern SSH connection manager",
Long: `SSH Manager (sshm) is a modern command-line tool for managing SSH connections.
It provides an interactive interface to browse and connect to your SSH hosts
configured in your ~/.ssh/config file.`,
Version: version,
Run: func(cmd *cobra.Command, args []string) {
// If no arguments provided, run interactive mode
if len(args) == 0 {
runInteractiveMode()
return
}
// If a host name is provided, connect directly
hostName := args[0]
connectToHost(hostName)
},
}
func runInteractiveMode() {
// Parse SSH configurations
hosts, err := config.ParseSSHConfig()
if err != nil {
log.Fatalf("Error reading SSH config file: %v", err)
}
if len(hosts) == 0 {
fmt.Println("No SSH hosts found in your ~/.ssh/config file.")
fmt.Print("Would you like to add a new host now? [y/N]: ")
var response string
_, err := fmt.Scanln(&response)
if err == nil && (response == "y" || response == "Y") {
err := ui.RunAddForm("")
if err != nil {
fmt.Printf("Error adding host: %v\n", err)
}
// After adding, try to reload hosts and continue if any exist
hosts, err = config.ParseSSHConfig()
if err != nil || len(hosts) == 0 {
fmt.Println("No hosts available, exiting.")
os.Exit(1)
}
} else {
fmt.Println("No hosts available, exiting.")
os.Exit(1)
}
}
// Run the interactive TUI
if err := ui.RunInteractiveMode(hosts); err != nil {
log.Fatalf("Error running interactive mode: %v", err)
}
}
func connectToHost(hostName string) {
// Parse SSH configurations to verify host exists
hosts, err := config.ParseSSHConfig()
if err != nil {
log.Fatalf("Error reading SSH config file: %v", err)
}
// Check if host exists
var hostFound bool
for _, host := range hosts {
if host.Name == hostName {
hostFound = true
break
}
}
if !hostFound {
fmt.Printf("Error: Host '%s' not found in SSH configuration.\n", hostName)
fmt.Println("Use 'sshm' to see available hosts.")
os.Exit(1)
}
// Connect to the host
fmt.Printf("Connecting to %s...\n", hostName)
// Note: In a real implementation, you'd use exec.Command here
// For now, just print the command that would be executed
fmt.Printf("ssh %s\n", hostName)
}
// Execute adds all child commands to the root command and sets flags appropriately.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

34
go.mod Normal file
View File

@ -0,0 +1,34 @@
module sshm
go 1.23.1
require (
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.9.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

63
go.sum Normal file
View File

@ -0,0 +1,63 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
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/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

BIN
images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
images/sshm.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

69
install/README.md Normal file
View File

@ -0,0 +1,69 @@
# Installation Scripts
This directory contains installation scripts for SSHM.
## Unix/Linux/macOS Installation
### Quick Install (Recommended)
```bash
curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash
```
**Note:** When using the pipe method, the installer will automatically proceed with installation if SSHM is already installed.
### Install Options
**Force install without prompts:**
```bash
FORCE_INSTALL=true bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)"
```
**Disable auto-install when using pipe:**
```bash
FORCE_INSTALL=false bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)"
```
### Manual Install
1. Download the script:
```bash
curl -O https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh
```
2. Make it executable:
```bash
chmod +x unix.sh
```
3. Run the installer:
```bash
./unix.sh
```
## What the installer does
1. **Detects your system** - Automatically detects your OS (Linux/macOS) and architecture (AMD64/ARM64)
2. **Fetches latest version** - Gets the latest release from GitHub
3. **Downloads binary** - Downloads the appropriate binary for your system
4. **Installs to /usr/local/bin** - Installs the binary with proper permissions
5. **Verifies installation** - Checks that the installation was successful
## Supported Platforms
- **Linux**: AMD64, ARM64
- **macOS**: AMD64 (Intel), ARM64 (Apple Silicon)
## Requirements
- `curl` - for downloading
- `tar` - for extracting archives
- `sudo` access - for installing to `/usr/local/bin`
## Uninstall
To uninstall SSHM:
```bash
sudo rm /usr/local/bin/sshm
```

173
install/unix.sh Normal file
View File

@ -0,0 +1,173 @@
#!/bin/bash
INSTALL_DIR="/usr/local/bin"
EXECUTABLE_NAME=sshm
EXECUTABLE_PATH="$INSTALL_DIR/$EXECUTABLE_NAME"
USE_SUDO="false"
OS=""
ARCH=""
FORCE_INSTALL="${FORCE_INSTALL:-false}"
RED='\033[0;31m'
PURPLE='\033[0;35m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
setSystem() {
ARCH=$(uname -m)
case $ARCH in
i386|i686) ARCH="amd64" ;;
x86_64) ARCH="amd64";;
armv6*) ARCH="arm64" ;;
armv7*) ARCH="arm64" ;;
aarch64*) ARCH="arm64" ;;
arm64) ARCH="arm64" ;;
esac
OS=$(echo `uname`|tr '[:upper:]' '[:lower:]')
# Determine if we need sudo
if [ "$OS" = "linux" ]; then
USE_SUDO="true"
fi
if [ "$OS" = "darwin" ]; then
USE_SUDO="true"
fi
}
runAsRoot() {
local CMD="$*"
if [ "$USE_SUDO" = "true" ]; then
printf "${PURPLE}We need sudo access to install SSHM to $INSTALL_DIR ${NC}\n"
CMD="sudo $CMD"
fi
$CMD
}
getLatestVersion() {
printf "${YELLOW}Fetching latest version...${NC}\n"
LATEST_VERSION=$(curl -s https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$LATEST_VERSION" ]; then
printf "${RED}Failed to fetch latest version${NC}\n"
exit 1
fi
printf "${GREEN}Latest version: $LATEST_VERSION${NC}\n"
}
downloadBinary() {
GITHUB_FILE="sshm-${OS}-${ARCH}.tar.gz"
GITHUB_URL="https://github.com/Gu1llaum-3/sshm/releases/download/$LATEST_VERSION/$GITHUB_FILE"
printf "${YELLOW}Downloading $GITHUB_FILE...${NC}\n"
curl -L "$GITHUB_URL" --progress-bar --output "sshm-tmp.tar.gz"
if [ $? -ne 0 ]; then
printf "${RED}Failed to download binary${NC}\n"
exit 1
fi
# Extract the binary
tar -xzf "sshm-tmp.tar.gz"
if [ $? -ne 0 ]; then
printf "${RED}Failed to extract binary${NC}\n"
exit 1
fi
# Find the extracted binary
EXTRACTED_BINARY=$(find . -name "sshm-${OS}-${ARCH}" -type f)
if [ -z "$EXTRACTED_BINARY" ]; then
printf "${RED}Could not find extracted binary${NC}\n"
exit 1
fi
mv "$EXTRACTED_BINARY" "sshm-tmp"
rm -f "sshm-tmp.tar.gz"
}
install() {
printf "${YELLOW}Installing SSHM...${NC}\n"
chmod +x "sshm-tmp"
if [ $? -ne 0 ]; then
printf "${RED}Failed to set permissions${NC}\n"
exit 1
fi
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
if [ $? -ne 0 ]; then
printf "${RED}Failed to install binary${NC}\n"
exit 1
fi
}
cleanup() {
rm -f "sshm-tmp" "sshm-tmp.tar.gz" "sshm-${OS}-${ARCH}"
}
checkExisting() {
if command -v sshm >/dev/null 2>&1; then
CURRENT_VERSION=$(sshm --version 2>/dev/null | grep -o 'version.*' | cut -d' ' -f2 || echo "unknown")
printf "${YELLOW}SSHM is already installed (version: $CURRENT_VERSION)${NC}\n"
# Check if FORCE_INSTALL is set
if [ "$FORCE_INSTALL" = "true" ]; then
printf "${GREEN}Force install enabled, proceeding with installation...${NC}\n"
return
fi
# Check if running via pipe (stdin is not a terminal)
if [ ! -t 0 ]; then
printf "${YELLOW}Running via pipe - automatically proceeding with installation...${NC}\n"
printf "${YELLOW}Use 'FORCE_INSTALL=false bash -c \"\$(curl -sSL ...)\"' to disable auto-install${NC}\n"
return
fi
printf "${YELLOW}Do you want to overwrite it? [y/N]: ${NC}"
read -r response
case "$response" in
[yY][eE][sS]|[yY])
printf "${GREEN}Proceeding with installation...${NC}\n"
;;
*)
printf "${GREEN}Installation cancelled.${NC}\n"
exit 0
;;
esac
fi
}
main() {
printf "${PURPLE}Installing SSHM - SSH Connection Manager${NC}\n\n"
# Check if already installed
checkExisting
# Set up system detection
setSystem
printf "${GREEN}Detected system: $OS ($ARCH)${NC}\n"
# Get latest version
getLatestVersion
# Download and install
downloadBinary
install
cleanup
printf "\n${GREEN}✅ SSHM was installed successfully to: ${NC}$EXECUTABLE_PATH\n"
printf "${GREEN}You can now use 'sshm' command to manage your SSH connections!${NC}\n\n"
# Show version
printf "${YELLOW}Verifying installation...${NC}\n"
if command -v sshm >/dev/null 2>&1; then
sshm --version
else
printf "${RED}Warning: 'sshm' command not found in PATH. You may need to restart your terminal or add $INSTALL_DIR to your PATH.${NC}\n"
fi
}
# Trap to cleanup on exit
trap cleanup EXIT
main "$@"

616
internal/config/ssh.go Normal file
View File

@ -0,0 +1,616 @@
package config
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
)
// SSHHost represents an SSH host configuration
type SSHHost struct {
Name string
Hostname string
User string
Port string
Identity string
ProxyJump string
Options string
Tags []string
}
// configMutex protects SSH config file operations from race conditions
var configMutex sync.Mutex
// backupConfig creates a backup of the SSH config file
func backupConfig(configPath string) error {
backupPath := configPath + ".backup"
src, err := os.Open(configPath)
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(backupPath)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
return err
}
// ParseSSHConfig parses the SSH config file and returns the list of hosts
func ParseSSHConfig() ([]SSHHost, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
configPath := filepath.Join(homeDir, ".ssh", "config")
return ParseSSHConfigFile(configPath)
}
// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
// Check if the file exists, otherwise create it (and the parent directory if needed)
if _, err := os.Stat(configPath); os.IsNotExist(err) {
dir := filepath.Dir(configPath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
err = os.MkdirAll(dir, 0700)
if err != nil {
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
}
}
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
}
file.Close()
// File created, return empty host list
return []SSHHost{}, nil
}
file, err := os.Open(configPath)
if err != nil {
return nil, err
}
defer file.Close()
var hosts []SSHHost
var currentHost *SSHHost
var pendingTags []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Ignore empty lines
if line == "" {
continue
}
// Check for tags comment
if strings.HasPrefix(line, "# Tags:") {
tagsStr := strings.TrimPrefix(line, "# Tags:")
tagsStr = strings.TrimSpace(tagsStr)
if tagsStr != "" {
// Split tags by comma and trim whitespace
for _, tag := range strings.Split(tagsStr, ",") {
tag = strings.TrimSpace(tag)
if tag != "" {
pendingTags = append(pendingTags, tag)
}
}
}
continue
}
// Ignore other comments
if strings.HasPrefix(line, "#") {
continue
}
// Split line into words
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
key := strings.ToLower(parts[0])
value := strings.Join(parts[1:], " ")
switch key {
case "host":
// New host, save previous one if it exists
if currentHost != nil {
hosts = append(hosts, *currentHost)
}
// Create new host
currentHost = &SSHHost{
Name: value,
Port: "22", // Default port
Tags: pendingTags, // Assign pending tags to this host
}
// Clear pending tags for next host
pendingTags = nil
case "hostname":
if currentHost != nil {
currentHost.Hostname = value
}
case "user":
if currentHost != nil {
currentHost.User = value
}
case "port":
if currentHost != nil {
currentHost.Port = value
}
case "identityfile":
if currentHost != nil {
currentHost.Identity = value
}
case "proxyjump":
if currentHost != nil {
currentHost.ProxyJump = value
}
default:
// Handle other SSH options
if currentHost != nil && strings.TrimSpace(line) != "" {
// Store options in config format (key value), not command format
if currentHost.Options == "" {
currentHost.Options = parts[0] + " " + value
} else {
currentHost.Options += "\n" + parts[0] + " " + value
}
}
}
}
// Add the last host if it exists
if currentHost != nil {
hosts = append(hosts, *currentHost)
}
return hosts, scanner.Err()
}
// AddSSHHost adds a new SSH host to the config file
func AddSSHHost(host SSHHost) error {
configMutex.Lock()
defer configMutex.Unlock()
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
configPath := filepath.Join(homeDir, ".ssh", "config")
// Create backup before modification if file exists
if _, err := os.Stat(configPath); err == nil {
if err := backupConfig(configPath); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
}
// Check if host already exists
exists, err := HostExists(host.Name)
if err != nil {
return err
}
if exists {
return fmt.Errorf("host '%s' already exists", host.Name)
}
// Open file in append mode
file, err := os.OpenFile(configPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return err
}
defer file.Close()
// Write the configuration
_, err = file.WriteString("\n")
if err != nil {
return err
}
// Write tags if present
if len(host.Tags) > 0 {
_, err = file.WriteString("# Tags: " + strings.Join(host.Tags, ", ") + "\n")
if err != nil {
return err
}
}
// Write host configuration
_, err = file.WriteString(fmt.Sprintf("Host %s\n", host.Name))
if err != nil {
return err
}
_, err = file.WriteString(fmt.Sprintf(" HostName %s\n", host.Hostname))
if err != nil {
return err
}
if host.User != "" {
_, err = file.WriteString(fmt.Sprintf(" User %s\n", host.User))
if err != nil {
return err
}
}
if host.Port != "" && host.Port != "22" {
_, err = file.WriteString(fmt.Sprintf(" Port %s\n", host.Port))
if err != nil {
return err
}
}
if host.Identity != "" {
_, err = file.WriteString(fmt.Sprintf(" IdentityFile %s\n", host.Identity))
if err != nil {
return err
}
}
if host.ProxyJump != "" {
_, err = file.WriteString(fmt.Sprintf(" ProxyJump %s\n", host.ProxyJump))
if err != nil {
return err
}
}
// Write SSH options
if host.Options != "" {
// Split options by newlines and write each one
options := strings.Split(host.Options, "\n")
for _, option := range options {
option = strings.TrimSpace(option)
if option != "" {
_, err = file.WriteString(fmt.Sprintf(" %s\n", option))
if err != nil {
return err
}
}
}
}
return nil
}
// ParseSSHOptionsFromCommand converts SSH command line options to config format
// Input: "-o Compression=yes -o ServerAliveInterval=60"
// Output: "Compression yes\nServerAliveInterval 60"
func ParseSSHOptionsFromCommand(options string) string {
if options == "" {
return ""
}
var result []string
parts := strings.Split(options, "-o")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Replace = with space for SSH config format
option := strings.ReplaceAll(part, "=", " ")
result = append(result, option)
}
return strings.Join(result, "\n")
}
// FormatSSHOptionsForCommand converts SSH config options to command line format
// Input: "Compression yes\nServerAliveInterval 60"
// Output: "-o Compression=yes -o ServerAliveInterval=60"
func FormatSSHOptionsForCommand(options string) string {
if options == "" {
return ""
}
var result []string
lines := strings.Split(options, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Replace space with = for command line format
parts := strings.SplitN(line, " ", 2)
if len(parts) == 2 {
result = append(result, fmt.Sprintf("-o %s=%s", parts[0], parts[1]))
} else {
result = append(result, fmt.Sprintf("-o %s", line))
}
}
return strings.Join(result, " ")
}
// HostExists checks if a host already exists in the config
func HostExists(hostName string) (bool, error) {
hosts, err := ParseSSHConfig()
if err != nil {
return false, err
}
for _, host := range hosts {
if host.Name == hostName {
return true, nil
}
}
return false, nil
}
// GetSSHHost retrieves a specific host configuration by name
func GetSSHHost(hostName string) (*SSHHost, error) {
hosts, err := ParseSSHConfig()
if err != nil {
return nil, err
}
for _, host := range hosts {
if host.Name == hostName {
return &host, nil
}
}
return nil, fmt.Errorf("host '%s' not found", hostName)
}
// UpdateSSHHost updates an existing SSH host configuration
func UpdateSSHHost(oldName string, newHost SSHHost) error {
configMutex.Lock()
defer configMutex.Unlock()
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
configPath := filepath.Join(homeDir, ".ssh", "config")
// Create backup before modification
if err := backupConfig(configPath); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
// Read the current config
content, err := os.ReadFile(configPath)
if err != nil {
return err
}
lines := strings.Split(string(content), "\n")
var newLines []string
i := 0
hostFound := false
for i < len(lines) {
line := strings.TrimSpace(lines[i])
// Check for tags comment followed by Host
if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) {
nextLine := strings.TrimSpace(lines[i+1])
if nextLine == "Host "+oldName {
// Found the host to update, skip the old configuration
hostFound = true
// Skip until we find the end of this host block (empty line or next Host)
i += 2 // Skip tags and Host line
for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") {
i++
}
// Skip any trailing empty lines after the host block
for i < len(lines) && strings.TrimSpace(lines[i]) == "" {
i++
}
// Insert new configuration at this position
// Add empty line only if the previous line is not empty
if len(newLines) > 0 && strings.TrimSpace(newLines[len(newLines)-1]) != "" {
newLines = append(newLines, "")
}
if len(newHost.Tags) > 0 {
newLines = append(newLines, "# Tags: "+strings.Join(newHost.Tags, ", "))
}
newLines = append(newLines, "Host "+newHost.Name)
newLines = append(newLines, " HostName "+newHost.Hostname)
if newHost.User != "" {
newLines = append(newLines, " User "+newHost.User)
}
if newHost.Port != "" && newHost.Port != "22" {
newLines = append(newLines, " Port "+newHost.Port)
}
if newHost.Identity != "" {
newLines = append(newLines, " IdentityFile "+newHost.Identity)
}
if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
}
// Write SSH options
if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n")
for _, option := range options {
option = strings.TrimSpace(option)
if option != "" {
newLines = append(newLines, " "+option)
}
}
}
// Add empty line after the host configuration for separation
newLines = append(newLines, "")
continue
}
}
// Check for Host line without tags
if strings.HasPrefix(line, "Host ") && strings.Fields(line)[1] == oldName {
hostFound = true
// Skip until we find the end of this host block
i++ // Skip Host line
for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") {
i++
}
// Skip any trailing empty lines after the host block
for i < len(lines) && strings.TrimSpace(lines[i]) == "" {
i++
}
// Insert new configuration
// Add empty line only if the previous line is not empty
if len(newLines) > 0 && strings.TrimSpace(newLines[len(newLines)-1]) != "" {
newLines = append(newLines, "")
}
if len(newHost.Tags) > 0 {
newLines = append(newLines, "# Tags: "+strings.Join(newHost.Tags, ", "))
}
newLines = append(newLines, "Host "+newHost.Name)
newLines = append(newLines, " HostName "+newHost.Hostname)
if newHost.User != "" {
newLines = append(newLines, " User "+newHost.User)
}
if newHost.Port != "" && newHost.Port != "22" {
newLines = append(newLines, " Port "+newHost.Port)
}
if newHost.Identity != "" {
newLines = append(newLines, " IdentityFile "+newHost.Identity)
}
if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
}
// Write SSH options
if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n")
for _, option := range options {
option = strings.TrimSpace(option)
if option != "" {
newLines = append(newLines, " "+option)
}
}
}
// Add empty line after the host configuration for separation
newLines = append(newLines, "")
continue
}
// Keep other lines as-is
newLines = append(newLines, lines[i])
i++
}
if !hostFound {
return fmt.Errorf("host '%s' not found", oldName)
}
// Write back to file
newContent := strings.Join(newLines, "\n")
return os.WriteFile(configPath, []byte(newContent), 0600)
}
// DeleteSSHHost removes an SSH host configuration from the config file
func DeleteSSHHost(hostName string) error {
configMutex.Lock()
defer configMutex.Unlock()
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
configPath := filepath.Join(homeDir, ".ssh", "config")
// Create backup before modification
if err := backupConfig(configPath); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
// Read the current config
content, err := os.ReadFile(configPath)
if err != nil {
return err
}
lines := strings.Split(string(content), "\n")
var newLines []string
i := 0
hostFound := false
for i < len(lines) {
line := strings.TrimSpace(lines[i])
// Check for tags comment followed by Host
if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) {
nextLine := strings.TrimSpace(lines[i+1])
if nextLine == "Host "+hostName {
// Found the host to delete, skip the configuration
hostFound = true
// Skip tags comment and Host line
i += 2
// Skip until we find the end of this host block (empty line or next Host)
for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") {
i++
}
// Skip any trailing empty lines after the host block
for i < len(lines) && strings.TrimSpace(lines[i]) == "" {
i++
}
continue
}
}
// Check for Host line without tags
if strings.HasPrefix(line, "Host ") && strings.Fields(line)[1] == hostName {
hostFound = true
// Skip Host line
i++
// Skip until we find the end of this host block
for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") {
i++
}
// Skip any trailing empty lines after the host block
for i < len(lines) && strings.TrimSpace(lines[i]) == "" {
i++
}
continue
}
// Keep other lines as-is
newLines = append(newLines, lines[i])
i++
}
if !hostFound {
return fmt.Errorf("host '%s' not found", hostName)
}
// Write back to file
newContent := strings.Join(newLines, "\n")
return os.WriteFile(configPath, []byte(newContent), 0600)
}

302
internal/ui/add_form.go Normal file
View File

@ -0,0 +1,302 @@
package ui
import (
"os"
"os/user"
"path/filepath"
"sshm/internal/config"
"sshm/internal/validation"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
titleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Background(lipgloss.Color("#25A065")).
Padding(0, 1)
fieldStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575"))
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262"))
)
type addFormModel struct {
inputs []textinput.Model
focused int
err string
success bool
}
const (
nameInput = iota
hostnameInput
userInput
portInput
identityInput
proxyJumpInput
optionsInput
tagsInput
)
func RunAddForm(hostname string) error {
// Get current user for default
currentUser, _ := user.Current()
defaultUser := "root"
if currentUser != nil {
defaultUser = currentUser.Username
}
// Find default identity file
homeDir, _ := os.UserHomeDir()
defaultIdentity := filepath.Join(homeDir, ".ssh", "id_rsa")
// Check for other common key types
keyTypes := []string{"id_ed25519", "id_ecdsa", "id_rsa"}
for _, keyType := range keyTypes {
keyPath := filepath.Join(homeDir, ".ssh", keyType)
if _, err := os.Stat(keyPath); err == nil {
defaultIdentity = keyPath
break
}
}
inputs := make([]textinput.Model, 8)
// Name input
inputs[nameInput] = textinput.New()
inputs[nameInput].Placeholder = "server-name"
inputs[nameInput].Focus()
inputs[nameInput].CharLimit = 50
inputs[nameInput].Width = 30
if hostname != "" {
inputs[nameInput].SetValue(hostname)
}
// Hostname input
inputs[hostnameInput] = textinput.New()
inputs[hostnameInput].Placeholder = "192.168.1.100 or example.com"
inputs[hostnameInput].CharLimit = 100
inputs[hostnameInput].Width = 30
// User input
inputs[userInput] = textinput.New()
inputs[userInput].Placeholder = defaultUser
inputs[userInput].CharLimit = 50
inputs[userInput].Width = 30
// Port input
inputs[portInput] = textinput.New()
inputs[portInput].Placeholder = "22"
inputs[portInput].CharLimit = 5
inputs[portInput].Width = 30
// Identity input
inputs[identityInput] = textinput.New()
inputs[identityInput].Placeholder = defaultIdentity
inputs[identityInput].CharLimit = 200
inputs[identityInput].Width = 50
// ProxyJump input
inputs[proxyJumpInput] = textinput.New()
inputs[proxyJumpInput].Placeholder = "user@jump-host:port or existing-host-name"
inputs[proxyJumpInput].CharLimit = 200
inputs[proxyJumpInput].Width = 50
// SSH Options input
inputs[optionsInput] = textinput.New()
inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60"
inputs[optionsInput].CharLimit = 500
inputs[optionsInput].Width = 70
// Tags input
inputs[tagsInput] = textinput.New()
inputs[tagsInput].Placeholder = "production, web, database"
inputs[tagsInput].CharLimit = 200
inputs[tagsInput].Width = 50
m := addFormModel{
inputs: inputs,
focused: nameInput,
}
p := tea.NewProgram(&m, tea.WithAltScreen())
_, err := p.Run()
return err
}
func (m *addFormModel) Init() tea.Cmd {
return textinput.Blink
}
func (m *addFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "ctrl+enter":
// Allow submission from any field with Ctrl+Enter
return m, m.submitForm()
case "tab", "shift+tab", "enter", "up", "down":
s := msg.String()
// Handle form submission
if s == "enter" && m.focused == len(m.inputs)-1 {
return m, m.submitForm()
}
// Cycle inputs
if s == "up" || s == "shift+tab" {
m.focused--
} else {
m.focused++
}
if m.focused > len(m.inputs)-1 {
m.focused = 0
} else if m.focused < 0 {
m.focused = len(m.inputs) - 1
}
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
continue
}
m.inputs[i].Blur()
}
return m, tea.Batch(cmds...)
}
case submitResult:
if msg.err != nil {
m.err = msg.err.Error()
} else {
m.success = true
m.err = ""
return m, tea.Quit
}
}
// Update inputs
cmd := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], cmd[i] = m.inputs[i].Update(msg)
}
cmds = append(cmds, cmd...)
return m, tea.Batch(cmds...)
}
func (m *addFormModel) View() string {
if m.success {
return ""
}
var b strings.Builder
b.WriteString(titleStyle.Render("Add SSH Host Configuration"))
b.WriteString("\n\n")
fields := []string{
"Host Name *",
"Hostname/IP *",
"User",
"Port",
"Identity File",
"ProxyJump",
"SSH Options",
"Tags (comma-separated)",
}
for i, field := range fields {
b.WriteString(fieldStyle.Render(field))
b.WriteString("\n")
b.WriteString(m.inputs[i].View())
b.WriteString("\n\n")
}
if m.err != "" {
b.WriteString(errorStyle.Render("Error: " + m.err))
b.WriteString("\n\n")
}
b.WriteString(helpStyle.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
b.WriteString("\n")
b.WriteString(helpStyle.Render("* Required fields"))
return b.String()
}
type submitResult struct {
hostname string
err error
}
func (m *addFormModel) submitForm() tea.Cmd {
return func() tea.Msg {
// Get values
name := strings.TrimSpace(m.inputs[nameInput].Value())
hostname := strings.TrimSpace(m.inputs[hostnameInput].Value())
user := strings.TrimSpace(m.inputs[userInput].Value())
port := strings.TrimSpace(m.inputs[portInput].Value())
identity := strings.TrimSpace(m.inputs[identityInput].Value())
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
options := strings.TrimSpace(m.inputs[optionsInput].Value())
// Set defaults
if user == "" {
user = m.inputs[userInput].Placeholder
}
if port == "" {
port = "22"
}
// Do not auto-fill identity with placeholder if left empty; keep it empty so it's optional
// Validate all fields
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
return submitResult{err: err}
}
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
var tags []string
if tagsStr != "" {
for _, tag := range strings.Split(tagsStr, ",") {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
}
// Create host configuration
host := config.SSHHost{
Name: name,
Hostname: hostname,
User: user,
Port: port,
Identity: identity,
ProxyJump: proxyJump,
Options: config.ParseSSHOptionsFromCommand(options),
Tags: tags,
}
// Add to config
err := config.AddSSHHost(host)
return submitResult{hostname: name, err: err}
}
}

281
internal/ui/edit_form.go Normal file
View File

@ -0,0 +1,281 @@
package ui
import (
"sshm/internal/config"
"sshm/internal/validation"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
titleStyleEdit = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Background(lipgloss.Color("#25A065")).
Padding(0, 1)
fieldStyleEdit = lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575"))
errorStyleEdit = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
helpStyleEdit = lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262"))
)
type editFormModel struct {
inputs []textinput.Model
focused int
err string
success bool
originalName string
}
func RunEditForm(hostName string) error {
// Get the existing host configuration
host, err := config.GetSSHHost(hostName)
if err != nil {
return err
}
inputs := make([]textinput.Model, 8)
// Name input
inputs[nameInput] = textinput.New()
inputs[nameInput].Placeholder = "server-name"
inputs[nameInput].Focus()
inputs[nameInput].CharLimit = 50
inputs[nameInput].Width = 30
inputs[nameInput].SetValue(host.Name)
// Hostname input
inputs[hostnameInput] = textinput.New()
inputs[hostnameInput].Placeholder = "192.168.1.100 or example.com"
inputs[hostnameInput].CharLimit = 100
inputs[hostnameInput].Width = 30
inputs[hostnameInput].SetValue(host.Hostname)
// User input
inputs[userInput] = textinput.New()
inputs[userInput].Placeholder = "root"
inputs[userInput].CharLimit = 50
inputs[userInput].Width = 30
inputs[userInput].SetValue(host.User)
// Port input
inputs[portInput] = textinput.New()
inputs[portInput].Placeholder = "22"
inputs[portInput].CharLimit = 5
inputs[portInput].Width = 30
inputs[portInput].SetValue(host.Port)
// Identity input
inputs[identityInput] = textinput.New()
inputs[identityInput].Placeholder = "~/.ssh/id_rsa"
inputs[identityInput].CharLimit = 200
inputs[identityInput].Width = 50
inputs[identityInput].SetValue(host.Identity)
// ProxyJump input
inputs[proxyJumpInput] = textinput.New()
inputs[proxyJumpInput].Placeholder = "user@jump-host:port or existing-host-name"
inputs[proxyJumpInput].CharLimit = 200
inputs[proxyJumpInput].Width = 50
inputs[proxyJumpInput].SetValue(host.ProxyJump)
// SSH Options input
inputs[optionsInput] = textinput.New()
inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60"
inputs[optionsInput].CharLimit = 500
inputs[optionsInput].Width = 70
inputs[optionsInput].SetValue(config.FormatSSHOptionsForCommand(host.Options))
// Tags input
inputs[tagsInput] = textinput.New()
inputs[tagsInput].Placeholder = "production, web, database"
inputs[tagsInput].CharLimit = 200
inputs[tagsInput].Width = 50
if len(host.Tags) > 0 {
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", "))
}
m := editFormModel{
inputs: inputs,
focused: nameInput,
originalName: hostName,
}
// Open in separate window like add form
p := tea.NewProgram(&m, tea.WithAltScreen())
_, err = p.Run()
return err
}
func (m *editFormModel) Init() tea.Cmd {
return textinput.Blink
}
func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "ctrl+enter":
// Allow submission from any field with Ctrl+Enter
return m, m.submitEditForm()
case "tab", "shift+tab", "enter", "up", "down":
s := msg.String()
// Handle form submission
if s == "enter" && m.focused == len(m.inputs)-1 {
return m, m.submitEditForm()
}
// Cycle inputs
if s == "up" || s == "shift+tab" {
m.focused--
} else {
m.focused++
}
if m.focused > len(m.inputs)-1 {
m.focused = 0
} else if m.focused < 0 {
m.focused = len(m.inputs) - 1
}
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
continue
}
m.inputs[i].Blur()
}
return m, tea.Batch(cmds...)
}
case editResult:
if msg.err != nil {
m.err = msg.err.Error()
} else {
m.success = true
m.err = ""
return m, tea.Quit
}
}
// Update inputs
cmd := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], cmd[i] = m.inputs[i].Update(msg)
}
cmds = append(cmds, cmd...)
return m, tea.Batch(cmds...)
}
func (m *editFormModel) View() string {
if m.success {
return ""
}
var b strings.Builder
b.WriteString(titleStyleEdit.Render("Edit SSH Host Configuration"))
b.WriteString("\n\n")
fields := []string{
"Host Name *",
"Hostname/IP *",
"User",
"Port",
"Identity File",
"ProxyJump",
"SSH Options",
"Tags (comma-separated)",
}
for i, field := range fields {
b.WriteString(fieldStyleEdit.Render(field))
b.WriteString("\n")
b.WriteString(m.inputs[i].View())
b.WriteString("\n\n")
}
if m.err != "" {
b.WriteString(errorStyleEdit.Render("Error: " + m.err))
b.WriteString("\n\n")
}
b.WriteString(helpStyleEdit.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
b.WriteString("\n")
b.WriteString(helpStyleEdit.Render("* Required fields"))
return b.String()
}
type editResult struct {
hostname string
err error
}
func (m *editFormModel) submitEditForm() tea.Cmd {
return func() tea.Msg {
// Get values
name := strings.TrimSpace(m.inputs[nameInput].Value())
hostname := strings.TrimSpace(m.inputs[hostnameInput].Value())
user := strings.TrimSpace(m.inputs[userInput].Value())
port := strings.TrimSpace(m.inputs[portInput].Value())
identity := strings.TrimSpace(m.inputs[identityInput].Value())
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
options := strings.TrimSpace(m.inputs[optionsInput].Value())
// Set defaults
if port == "" {
port = "22"
}
// Do not auto-fill identity with placeholder if left empty; keep it empty so it's optional
// Validate all fields
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
return editResult{err: err}
}
// Parse tags
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
var tags []string
if tagsStr != "" {
for _, tag := range strings.Split(tagsStr, ",") {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
}
// Create updated host configuration
host := config.SSHHost{
Name: name,
Hostname: hostname,
User: user,
Port: port,
Identity: identity,
ProxyJump: proxyJump,
Options: config.ParseSSHOptionsFromCommand(options),
Tags: tags,
}
// Update the configuration
err := config.UpdateSSHHost(m.originalName, host)
return editResult{hostname: name, err: err}
}
}

496
internal/ui/tui.go Normal file
View File

@ -0,0 +1,496 @@
package ui
import (
"fmt"
"os/exec"
"sort"
"strings"
"sshm/internal/config"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
var searchStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("36")).
Padding(0, 1)
type Model struct {
table table.Model
searchInput textinput.Model
hosts []config.SSHHost
filteredHosts []config.SSHHost
searchMode bool
deleteMode bool
deleteHost string
exitAction string
exitHostName string
}
func (m Model) Init() tea.Cmd {
return textinput.Blink
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
// Handle key messages
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "ctrl+c":
if m.searchMode {
// Exit search mode
m.searchMode = false
m.searchInput.Blur()
m.table.Focus()
return m, nil
}
if m.deleteMode {
// Exit delete mode
m.deleteMode = false
m.deleteHost = ""
m.table.Focus()
return m, nil
}
return m, tea.Quit
case "q":
if !m.searchMode && !m.deleteMode {
return m, tea.Quit
}
case "/", "ctrl+f":
if !m.searchMode && !m.deleteMode {
// Enter search mode
m.searchMode = true
m.table.Blur()
m.searchInput.Focus()
return m, textinput.Blink
}
case "enter":
if m.searchMode {
// Exit search mode and focus table
m.searchMode = false
m.searchInput.Blur()
m.table.Focus()
return m, nil
} else if m.deleteMode {
// Confirm deletion
err := config.DeleteSSHHost(m.deleteHost)
if err != nil {
// Could show error message here
m.deleteMode = false
m.deleteHost = ""
m.table.Focus()
return m, nil
}
// Refresh the host list
hosts, err := config.ParseSSHConfig()
if err != nil {
// Could show error message here
m.deleteMode = false
m.deleteHost = ""
m.table.Focus()
return m, nil
}
m.hosts = sortHostsByName(hosts)
m.filteredHosts = m.hosts
m.updateTableRows()
m.deleteMode = false
m.deleteHost = ""
m.table.Focus()
return m, nil
} else {
// Connect to selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // Host name is in the first column
return m, tea.ExecProcess(exec.Command("ssh", hostName), func(err error) tea.Msg {
return tea.Quit()
})
}
}
case "e":
if !m.searchMode && !m.deleteMode {
// Edit selected host using dedicated edit form
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // Host name is in the first column
// Store the edit action and exit
m.exitAction = "edit"
m.exitHostName = hostName
return m, tea.Quit
}
}
case "a":
if !m.searchMode && !m.deleteMode {
// Add new host using dedicated add form
m.exitAction = "add"
return m, tea.Quit
}
case "d":
if !m.searchMode && !m.deleteMode {
// Delete selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // Host name is in the first column
m.deleteMode = true
m.deleteHost = hostName
m.table.Blur()
return m, nil
}
}
}
}
// Update components based on mode
if m.searchMode {
m.searchInput, cmd = m.searchInput.Update(msg)
// Filter hosts when search input changes
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
} else {
m.table, cmd = m.table.Update(msg)
}
return m, cmd
}
func (m Model) View() string {
if m.deleteMode {
return m.renderDeleteConfirmation()
}
var view strings.Builder
// Add search bar
searchPrompt := "Search (/ to search, ESC to exit search): "
if m.searchMode {
view.WriteString(searchStyle.Render(searchPrompt+m.searchInput.View()) + "\n\n")
}
// Add table
view.WriteString(baseStyle.Render(m.table.View()))
// Add help text
if !m.searchMode {
view.WriteString("\nUse ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • (q)uit")
} else {
view.WriteString("\nType to filter hosts by name or tag • Enter to select • ESC to exit search")
}
return view.String()
}
// sortHostsByName sorts a slice of SSH hosts alphabetically by name
func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
sorted := make([]config.SSHHost, len(hosts))
copy(sorted, hosts)
sort.Slice(sorted, func(i, j int) bool {
return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name)
})
return sorted
}
// calculateNameColumnWidth calculates the optimal width for the Name column
// based on the longest host name, with a minimum of 8 and maximum of 40 characters
func calculateNameColumnWidth(hosts []config.SSHHost) int {
maxLength := 8 // Minimum width to accommodate the "Name" header
for _, host := range hosts {
if len(host.Name) > maxLength {
maxLength = len(host.Name)
}
}
// Add some padding (2 characters) for better visual spacing
maxLength += 2
// Cap the maximum width to avoid extremely wide columns
if maxLength > 40 {
maxLength = 40
}
return maxLength
}
// calculateTagsColumnWidth calculates the optimal width for the Tags column
// based on the longest tags string, with a minimum of 8 and maximum of 50 characters
func calculateTagsColumnWidth(hosts []config.SSHHost) int {
maxLength := 8 // Minimum width to accommodate the "Tags" header
for _, host := range hosts {
// Format tags exactly the same way they appear in the table
var tagsStr string
if len(host.Tags) > 0 {
// Add # prefix to each tag and join with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
if len(tagsStr) > maxLength {
maxLength = len(tagsStr)
}
}
// Add some padding (2 characters) for better visual spacing
maxLength += 2
// Cap the maximum width to avoid extremely wide columns
if maxLength > 50 {
maxLength = 50
}
return maxLength
}
// NewModel creates a new TUI model with the given SSH hosts
func NewModel(hosts []config.SSHHost) Model {
// Sort hosts alphabetically by name
sortedHosts := sortHostsByName(hosts)
// Create search input
ti := textinput.New()
ti.Placeholder = "Search hosts or tags..."
ti.CharLimit = 50
ti.Width = 50
// Calculate optimal width for the Name column
nameWidth := calculateNameColumnWidth(sortedHosts)
// Calculate optimal width for the Tags column
tagsWidth := calculateTagsColumnWidth(sortedHosts)
// Create table columns
columns := []table.Column{
{Title: "Name", Width: nameWidth},
{Title: "Hostname", Width: 25},
{Title: "User", Width: 12},
{Title: "Port", Width: 6},
{Title: "Tags", Width: tagsWidth},
}
// Convert hosts to table rows
var rows []table.Row
for _, host := range sortedHosts {
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
// Add # prefix to each tag and join with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
rows = append(rows, table.Row{
host.Name,
host.Hostname,
host.User,
host.Port,
tagsStr,
})
}
// Déterminer la hauteur du tableau : 1 (header) + nombre de hosts (max 10)
hostCount := len(rows)
tableHeight := 1 // header
if hostCount < 10 {
tableHeight += hostCount
} else {
tableHeight += 10
}
// Create table
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(tableHeight),
)
// Style the table
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(false)
s.Selected = s.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
t.SetStyles(s)
return Model{
table: t,
searchInput: ti,
hosts: sortedHosts,
filteredHosts: sortedHosts,
searchMode: false,
}
}
// RunInteractiveMode starts the interactive TUI
func RunInteractiveMode(hosts []config.SSHHost) error {
for {
m := NewModel(hosts)
// Start the application in terminal (without alt screen)
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return fmt.Errorf("error running TUI: %w", err)
}
// Check if the final model indicates an action
if model, ok := finalModel.(Model); ok {
if model.exitAction == "edit" && model.exitHostName != "" {
// Launch the dedicated edit form (opens in separate window)
if err := RunEditForm(model.exitHostName); err != nil {
fmt.Printf("Error editing host: %v\n", err)
// Continue the loop to return to the main interface
continue
}
// Clear screen before returning to TUI
fmt.Print("\033[2J\033[H")
// Refresh the hosts list after editing
refreshedHosts, err := config.ParseSSHConfig()
if err != nil {
return fmt.Errorf("error refreshing hosts after edit: %w", err)
}
hosts = refreshedHosts
// Continue the loop to return to the main interface
continue
} else if model.exitAction == "add" {
// Launch the dedicated add form (opens in separate window)
if err := RunAddForm(""); err != nil {
fmt.Printf("Error adding host: %v\n", err)
// Continue the loop to return to the main interface
continue
}
// Clear screen before returning to TUI
fmt.Print("\033[2J\033[H")
// Refresh the hosts list after adding
refreshedHosts, err := config.ParseSSHConfig()
if err != nil {
return fmt.Errorf("error refreshing hosts after add: %w", err)
}
hosts = refreshedHosts
// Continue the loop to return to the main interface
continue
}
}
// If no special command, exit normally
break
}
return nil
}
// filterHosts filters hosts based on search query (name or tags)
func (m Model) filterHosts(query string) []config.SSHHost {
if query == "" {
return sortHostsByName(m.hosts)
}
query = strings.ToLower(query)
var filtered []config.SSHHost
for _, host := range m.hosts {
// Check host name
if strings.Contains(strings.ToLower(host.Name), query) {
filtered = append(filtered, host)
continue
}
// Check hostname
if strings.Contains(strings.ToLower(host.Hostname), query) {
filtered = append(filtered, host)
continue
}
// Check tags
for _, tag := range host.Tags {
if strings.Contains(strings.ToLower(tag), query) {
filtered = append(filtered, host)
break
}
}
}
return sortHostsByName(filtered)
}
// updateTableRows updates the table with filtered hosts
func (m *Model) updateTableRows() {
var rows []table.Row
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
// Sort hosts alphabetically by name
sortedHosts := sortHostsByName(hostsToShow)
for _, host := range sortedHosts {
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
// Add # prefix to each tag and join with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
rows = append(rows, table.Row{
host.Name,
host.Hostname,
host.User,
host.Port,
tagsStr,
})
}
m.table.SetRows(rows)
}
// enterEditMode initializes edit mode for a specific host
// renderDeleteConfirmation renders the delete confirmation dialog
func (m Model) renderDeleteConfirmation() string {
var view strings.Builder
view.WriteString(lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("1")). // Red border
Padding(1, 2).
Render(fmt.Sprintf("⚠️ Delete SSH Host\n\nAre you sure you want to delete host '%s'?\n\nThis action cannot be undone.\n\nPress Enter to confirm or Esc to cancel", m.deleteHost)))
return view.String()
}

View File

@ -0,0 +1,96 @@
package validation
import (
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// ValidateHostname checks if a hostname is valid
func ValidateHostname(hostname string) bool {
if len(hostname) == 0 || len(hostname) > 253 {
return false
}
if strings.HasPrefix(hostname, ".") || strings.HasSuffix(hostname, ".") {
return false
}
if strings.Contains(hostname, " ") {
return false
}
hostnameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
return hostnameRegex.MatchString(hostname)
}
// ValidateIP checks if an IP address is valid
func ValidateIP(ip string) bool {
return net.ParseIP(ip) != nil
}
// ValidatePort checks if a port is valid
func ValidatePort(port string) bool {
if port == "" {
return true // Empty port defaults to 22
}
portNum, err := strconv.Atoi(port)
return err == nil && portNum >= 1 && portNum <= 65535
}
// ValidateHostName checks if a host name is valid for SSH config
func ValidateHostName(name string) bool {
if len(name) == 0 || len(name) > 50 {
return false
}
// Host name cannot contain whitespace or special SSH config characters
return !strings.ContainsAny(name, " \t\n\r#")
}
// ValidateIdentityFile checks if an identity file path is valid
func ValidateIdentityFile(path string) bool {
if path == "" {
return true // Optional field
}
// Expand ~ to home directory
if strings.HasPrefix(path, "~/") {
homeDir, err := os.UserHomeDir()
if err != nil {
return false
}
path = filepath.Join(homeDir, path[2:])
}
_, err := os.Stat(path)
return err == nil
}
// ValidateHost validates all host fields
func ValidateHost(name, hostname, port, identity string) error {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("host name is required")
}
if !ValidateHostName(name) {
return fmt.Errorf("invalid host name: cannot contain spaces or special characters")
}
if strings.TrimSpace(hostname) == "" {
return fmt.Errorf("hostname/IP is required")
}
if !ValidateHostname(hostname) && !ValidateIP(hostname) {
return fmt.Errorf("invalid hostname or IP address format")
}
if !ValidatePort(port) {
return fmt.Errorf("port must be between 1 and 65535")
}
if identity != "" && !ValidateIdentityFile(identity) {
return fmt.Errorf("identity file does not exist: %s", identity)
}
return nil
}

7
main.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "sshm/cmd"
func main() {
cmd.Execute()
}