mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-09-03 01:06:32 +02:00
first commit
This commit is contained in:
commit
fad2585d5e
104
.github/workflows/build.yml
vendored
Normal file
104
.github/workflows/build.yml
vendored
Normal 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
64
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
293
README.md
Normal file
@ -0,0 +1,293 @@
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="images/logo.png" alt="SSHM Logo" width="120" />
|
||||
</p>
|
||||
|
||||
# 🚀 SSHM - SSH Manager
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://github.com/Gu1llaum-3/sshm/releases)
|
||||
[](LICENSE)
|
||||
[](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
30
cmd/add.go
Normal 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
27
cmd/edit.go
Normal 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
107
cmd/root.go
Normal 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
34
go.mod
Normal 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
63
go.sum
Normal 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
BIN
images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 133 KiB |
BIN
images/sshm.gif
Normal file
BIN
images/sshm.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 615 KiB |
69
install/README.md
Normal file
69
install/README.md
Normal 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
173
install/unix.sh
Normal 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
616
internal/config/ssh.go
Normal 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
302
internal/ui/add_form.go
Normal 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
281
internal/ui/edit_form.go
Normal 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
496
internal/ui/tui.go
Normal 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()
|
||||
}
|
96
internal/validation/ssh.go
Normal file
96
internal/validation/ssh.go
Normal 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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user