29 Commits

Author SHA1 Message Date
ab5f430ee1 feat: add port forwarding history persistence 2025-09-12 11:29:17 +02:00
3da3a33530 feat: add direct host connection via sshm <host> with history tracking 2025-09-11 19:41:04 +02:00
12c1ab476c feat: centralize history storage in config directory
Automatically migrates existing ~/.ssh/sshm_history.json to platform-appropriate config location
2025-09-11 17:10:39 +02:00
e0e50ebfd0 fix(cmd): export variables for test accessibility
Export rootCmd->RootCmd and appVersion->AppVersion to fix test compilation errors. Update all references across cmd package and tests.
2025-09-10 11:13:15 +02:00
947afb2bbe build: strip 'v' prefix from version tag for binary
- Remove the 'v' prefix from the Git tag before injecting the version into the built binary
- Ensures the version string in the CLI does not include a leading 'v' (e.g. '1.2.3' instead of 'v1.2.3')
2025-09-10 10:40:22 +02:00
fe529792e3 Merge branch 'feature/add-move-command' into dev
Add move command feature:
- Allows moving a host from one config file to another when using includes
- Available both in the TUI interface and via CLI with 'sshm move'
2025-09-10 09:37:24 +02:00
5ee623d054 Merge branch 'feature/update-checker' into dev
Add update checker feature:
- Show update availability in the TUI interface
- Display update notification with sshm -v or --version
2025-09-10 09:34:01 +02:00
ef075e74cf test: add comprehensive test suite and fix failing tests
- Fix history tests with proper test isolation using temp files
- Fix CMD tests with proper string contains and simplified assertions
- Add missing test utilities and helper functions
- Improve test coverage across all packages
- Remove flaky tests and replace with robust alternatives"
2025-09-10 08:15:46 +02:00
9bb5d18f8e fix: resolve search behavior when cursor is not at top of list
- Fix search mode not triggering properly after navigation
- Preserve cursor position during filtering operations
- Add comprehensive UI tests for search functionality
- Improve search to include user field filtering
2025-09-08 16:37:25 +02:00
09423287fd feat: add move command to relocate SSH hosts between config files
- Add 'move' command with interactive file selector
- Implement atomic host moving between SSH config files
- Support for configs with include directives
- Add comprehensive error handling and validation
- Update help screen with improved two-column layout
2025-09-08 16:26:30 +02:00
4767267387 feat: add automatic version update checking and notifications
- Add internal/version module for GitHub release checking
- Integrate async version check in Bubble Tea UI
- Display update notification in main interface
- Add version check to --version/-v command output
- Include comprehensive version comparison and error handling
- Add unit tests for version parsing and comparison logic
2025-09-08 14:46:05 +02:00
44ffa0c31d fix: correct version injection in build process 2025-09-07 08:37:23 +02:00
edf61049fc fix: update module path to use GitHub URL 2025-09-06 23:48:30 +02:00
67987e6242 refactor: move SSH backups to ~/.config/sshm/backups/ 2025-09-06 23:36:12 +02:00
e1efef4680 ui: reduce search bar width to 25 characters 2025-09-06 17:37:46 +02:00
42387eb1fa feat: add async SSH ping for all hosts with status indicator 2025-09-06 17:20:14 +02:00
6577002e2b feat: add automatic pre-release detection for beta tags 2025-09-06 10:51:08 +02:00
be3dcaa1cd fix: enable editing and management of hosts from included SSH config files
• Add SourceFile field to SSHHost struct to track config file origins
• Implement FindHostInAllConfigs() to locate hosts across all config files
• Fix "host not found" errors when editing/deleting hosts from included files
• Add GetAllConfigFiles() and GetAllConfigFilesFromBase() for config discovery
• Create UpdateSSHHostV2() and DeleteSSHHostV2() for cross-file operations
• Display config file source in edit and info forms for better visibility
• Add intelligent file selector for host addition when multiple configs exist
• Support -c parameter context with proper file resolution
• Exclude .backup files from Include directive processing
• Maintain backward compatibility with existing SSH config workflows

Resolves limitation where hosts from included config files could be viewed
but not edited, deleted, or properly managed through the interface.
2025-09-05 17:04:11 +02:00
b67f5abbbc docs: update README features 2025-09-05 12:46:16 +02:00
b587defabc fix: improve TUI layout responsiveness for large host lists 2025-09-05 12:35:02 +02:00
22586484c7 Merge branch 'main' into feature/tui-refactor 2025-09-05 12:17:30 +02:00
420db56ff5 fix: improve table height calculation for better UI responsiveness 2025-09-05 12:08:52 +02:00
Guillaume Archambault
7600eaaa9b Merge pull request #3 from yimeng/main
This commit adds comprehensive support for SSH Include directives, 
allowing users to organize SSH configurations across multiple files.

Features:
- Support for Include directives with glob patterns (e.g., Include config.d/*)
- Circular include detection to prevent infinite recursion
- Graceful handling of missing include files
- Wildcard host filtering (ignores Host * patterns)
- Tilde expansion for home directory paths
- Relative path resolution from config file directory

The implementation maintains backward compatibility and includes 
comprehensive test coverage for all edge cases.

Thanks to @yimeng for this excellent contribution!
2025-09-05 11:53:04 +02:00
yimeng
e0dd32993a add ssh config include 2025-09-05 12:43:34 +08:00
1cea3795e4 feat: refactor TUI with read-only info view and optimized layout
- Add new 'i' command for read-only host information display
- Implement info view with option to switch to edit mode (e/Enter)
- Hide User and Port columns to optimize table space usage
- Improve table height calculation for better host visibility
- Add proper message handling for info view navigation
- Interface optimization
2025-09-04 16:47:07 +02:00
2ade315ddc fix: resolve macOS permission errors during SSHM installation
- Replace recursive find with direct file path check to avoid scanning protected directories
- Backup old binary during installation to prevent interference from old version
- Use full path for version verification to ensure new binary is tested
- Fixes 'Operation not permitted' errors on macOS during installation process
2025-09-04 12:30:18 +02:00
2deec405f7 feat: add port forwarding support 2025-09-04 10:34:54 +02:00
21c5d41977 feat: add local binary support to Windows installer 2025-09-03 18:12:01 +02:00
20bc506e36 feat: add Windows platform support 2025-09-03 17:48:58 +02:00
45 changed files with 6069 additions and 319 deletions

View File

@@ -34,6 +34,14 @@ jobs:
- goos: darwin
goarch: arm64
suffix: darwin-arm64
# Windows AMD64
- goos: windows
goarch: amd64
suffix: windows-amd64
# Windows ARM64
- goos: windows
goarch: arm64
suffix: windows-arm64
steps:
- name: Checkout code
@@ -60,19 +68,32 @@ jobs:
run: |
mkdir -p dist
VERSION=${GITHUB_REF#refs/tags/}
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
# Remove 'v' prefix if present for version injection
VERSION_CLEAN=${VERSION#v}
if [ "${{ matrix.goos }}" = "windows" ]; then
go build -ldflags="-s -w -X github.com/Gu1llaum-3/sshm/cmd.version=${VERSION_CLEAN}" -o dist/sshm-${{ matrix.suffix }}.exe .
else
go build -ldflags="-s -w -X github.com/Gu1llaum-3/sshm/cmd.version=${VERSION_CLEAN}" -o dist/sshm-${{ matrix.suffix }} .
fi
- name: Create tarball
- name: Create archive
run: |
cd dist
if [ "${{ matrix.goos }}" = "windows" ]; then
zip sshm-${{ matrix.suffix }}.zip sshm-${{ matrix.suffix }}.exe
rm sshm-${{ matrix.suffix }}.exe
else
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
rm sshm-${{ matrix.suffix }}
fi
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: sshm-${{ matrix.suffix }}
path: dist/sshm-${{ matrix.suffix }}.tar.gz
path: |
dist/sshm-${{ matrix.suffix }}.tar.gz
dist/sshm-${{ matrix.suffix }}.zip
release:
name: Create Release
@@ -91,14 +112,25 @@ jobs:
run: |
mkdir -p release
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
find ./artifacts -name "*.zip" -exec cp {} ./release/ \;
ls -la ./release/
- name: Check if pre-release
id: check_prerelease
run: |
if [[ "${GITHUB_REF#refs/tags/}" == *"-beta"* ]] || [[ "${GITHUB_REF#refs/tags/}" == *"-alpha"* ]] || [[ "${GITHUB_REF#refs/tags/}" == *"-rc"* ]] || [[ "${GITHUB_REF#refs/tags/}" == *"-dev"* ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: ./release/*
draft: false
prerelease: false
prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
generate_release_notes: true
name: ${{ github.ref_name }}${{ steps.check_prerelease.outputs.is_prerelease == 'true' && ' (Pre-release)' || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

196
README.md
View File

@@ -9,7 +9,7 @@
[![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)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey?style=for-the-badge)](https://github.com/Gu1llaum-3/sshm/releases)
> **A modern, interactive SSH Manager for your terminal** 🔥
@@ -28,11 +28,13 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
### 🎯 **Core Features**
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
- **⚡ Quick Connect** - Connect to any host instantly
- **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding
- **📝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
- **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag
- **📂 SSH Include Support** - Full support for SSH Include directives to organize configurations across multiple files
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
@@ -40,6 +42,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
- **Add new SSH hosts** with interactive forms
- **Edit existing configurations** in-place
- **Delete hosts** with confirmation prompts
- **Port forwarding setup** with intuitive interface for Local (-L), Remote (-R), and Dynamic (-D) forwarding
- **Backup configurations** automatically before changes
- **Validate settings** to prevent configuration errors
- **ProxyJump support** for secure connection tunneling through bastion hosts
@@ -49,19 +52,26 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
### 🎮 **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)
- **Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows
- **Lightweight** - Single binary with no dependencies
## 🚀 Quick Start
### Installation
**One-line install (Recommended):**
**Unix/Linux/macOS (One-line install):**
```bash
curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash
```
**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
```
**Alternative methods:**
*Linux/macOS:*
```bash
# Download specific release
wget https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz
@@ -71,6 +81,14 @@ tar -xzf sshm-linux-amd64.tar.gz
sudo mv sshm-linux-amd64 /usr/local/bin/sshm
```
*Windows:*
```powershell
# Download and extract
Invoke-WebRequest -Uri "https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-amd64.zip" -OutFile "sshm-windows-amd64.zip"
Expand-Archive sshm-windows-amd64.zip -DestinationPath C:\tools\
# Add C:\tools to your PATH environment variable
```
## 📖 Usage
### Interactive Mode
@@ -87,6 +105,7 @@ sshm
- `a` - Add new host
- `e` - Edit selected host
- `d` - Delete selected host
- `f` - Port forwarding setup
- `q` - Quit
- `/` - Search/filter hosts
@@ -107,6 +126,90 @@ The interactive forms will guide you through configuration:
- **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`)
- **Tags** - Comma-separated tags for organization
### Port Forwarding
SSHM provides an intuitive interface for setting up SSH port forwarding. Press `f` while selecting a host to open the port forwarding setup:
**Forward Types:**
- **Local (-L)** - Forward a local port to a remote host/port through the SSH connection
- Example: Access a remote database on `localhost:5432` via local port `15432`
- Use case: `ssh -L 15432:localhost:5432 server` → Database accessible on `localhost:15432`
- **Remote (-R)** - Forward a remote port back to a local host/port
- Example: Expose local web server on remote host's port `8080`
- Use case: `ssh -R 8080:localhost:3000 server` → Local app accessible from remote host's port 8080
- ⚠️ **Requirements for external access:**
- **SSH Server Config**: Add `GatewayPorts yes` to `/etc/ssh/sshd_config` and restart SSH service
- **Firewall**: Open the remote port in the server's firewall (`ufw allow 8080` or equivalent)
- **Port Availability**: Ensure the remote port is not already in use
- **Bind Address**: Use `0.0.0.0` for external access, `127.0.0.1` for local-only
- **Dynamic (-D)** - Create a SOCKS proxy for secure browsing
- Example: Route web traffic through the SSH connection
- Use case: `ssh -D 1080 server` → Configure browser to use `localhost:1080` as SOCKS proxy
- ⚠️ **Configuration requirements:**
- **Browser Setup**: Configure SOCKS v5 proxy in browser settings
- **DNS**: Enable "Proxy DNS when using SOCKS v5" for full privacy
- **Applications**: Only SOCKS-aware applications will use the proxy
- **Bind Address**: Use `127.0.0.1` for security (local access only)
**Port Forwarding Interface:**
- Choose forward type with ←/→ arrow keys
- Configure ports and addresses with guided forms
- Optional bind address configuration (defaults to 127.0.0.1)
- Real-time validation of port numbers and addresses
- Connect automatically with configured forwarding options
**Troubleshooting Port Forwarding:**
*Remote Forwarding Issues:*
```bash
# Error: "remote port forwarding failed for listen port X"
# Solutions:
1. Check if port is already in use: ssh server "netstat -tln | grep :X"
2. Use a different port that's available
3. Enable GatewayPorts in SSH config for external access
```
*SSH Server Configuration for Remote Forwarding:*
```bash
# Edit SSH daemon config on the server:
sudo nano /etc/ssh/sshd_config
# Add or uncomment:
GatewayPorts yes
# Restart SSH service:
sudo systemctl restart sshd # Ubuntu/Debian/CentOS 7+
# OR
sudo service ssh restart # Older systems
```
*Firewall Configuration:*
```bash
# Ubuntu/Debian (UFW):
sudo ufw allow [port_number]
# CentOS/RHEL/Rocky (firewalld):
sudo firewall-cmd --add-port=[port_number]/tcp --permanent
sudo firewall-cmd --reload
# Check if port is accessible:
telnet [server_ip] [port_number]
```
*Dynamic Forwarding (SOCKS) Browser Setup:*
```
Firefox: about:preferences → Network Settings
- Manual proxy configuration
- SOCKS Host: localhost, Port: [your_port]
- SOCKS v5: ✓
- Proxy DNS when using SOCKS v5: ✓
Chrome: Launch with proxy
chrome --proxy-server="socks5://localhost:[your_port]"
```
### CLI Usage
SSHM provides both command-line operations and an interactive TUI interface:
@@ -140,6 +243,29 @@ sshm --version
sshm --help
```
### Backup Configuration
SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe.
**Backup Location:**
- **Unix/Linux/macOS**: `~/.config/sshm/backups/` (or `$XDG_CONFIG_HOME/sshm/backups/` if set)
- **Windows**: `%APPDATA%\sshm\backups\` (fallback: `%USERPROFILE%\.config\sshm\backups\`)
**Key Features:**
- Automatic backup before any modification
- One backup per file (overwrites previous backup)
- Stored separately to avoid SSH Include conflicts
- Easy manual recovery if needed
**Quick Recovery:**
```bash
# Unix/Linux/macOS
cp ~/.config/sshm/backups/config.backup ~/.ssh/config
# Windows
copy "%APPDATA%\sshm\backups\config.backup" "%USERPROFILE%\.ssh\config"
```
### Configuration File Options
By default, SSHM uses the standard SSH configuration file at `~/.ssh/config`. You can specify a different configuration file using the `-c` flag:
@@ -153,11 +279,72 @@ sshm add hostname -c /path/to/custom/ssh_config
sshm edit hostname -c /path/to/custom/ssh_config
```
### Platform-Specific Notes
**Windows:**
- SSHM works with the built-in OpenSSH client (Windows 10/11)
- Configuration file location: `%USERPROFILE%\.ssh\config`
- Compatible with WSL SSH configurations
- Supports the same SSH options as Unix systems
**Unix/Linux/macOS:**
- Standard SSH configuration file: `~/.ssh/config`
- Full compatibility with OpenSSH features
- Preserves file permissions automatically
## 🏗️ 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.
### SSH Include Support
SSHM fully supports SSH Include directives, allowing you to organize your SSH configurations across multiple files. This is particularly useful for managing large numbers of hosts or organizing configurations by environment, project, or team.
**Include Examples:**
```ssh
# Main ~/.ssh/config file
Host personal-server
HostName personal.example.com
User myuser
# Include work-related configurations
Include work-servers.conf
# Include all configurations from a directory
Include projects/*
# Include with relative paths
Include ~/.ssh/configs/production.conf
```
**Organization Examples:**
*work-servers.conf:*
```ssh
# Tags: work, production
Host prod-web-01
HostName 10.0.1.10
User deploy
ProxyJump bastion.company.com
# Tags: work, staging
Host staging-api
HostName staging-api.company.com
User developer
```
*projects/client-alpha.conf:*
```ssh
# Tags: client, development
Host client-alpha-dev
HostName dev.client-alpha.com
User admin
Port 2222
```
**Example configuration:**
Include ~/.ssh/conf.d/*
```ssh
# Tags: production, web, frontend
Host web-prod-01
@@ -314,6 +501,8 @@ Automated releases are built for multiple platforms:
| 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) |
| Windows | AMD64 | [sshm-windows-amd64.zip](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-amd64.zip) |
| Windows | ARM64 | [sshm-windows-arm64.zip](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-arm64.zip) |
## 🤝 Contributing
@@ -335,6 +524,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- [Charm](https://charm.sh/) for the amazing TUI libraries
- [Cobra](https://cobra.dev/) for the excellent CLI framework
- [@yimeng](https://github.com/yimeng) for contributing SSH Include directive support
- The Go community for building such fantastic tools
---

View File

@@ -2,7 +2,8 @@ package cmd
import (
"fmt"
"sshm/internal/ui"
"github.com/Gu1llaum-3/sshm/internal/ui"
"github.com/spf13/cobra"
)
@@ -26,5 +27,5 @@ var addCmd = &cobra.Command{
}
func init() {
rootCmd.AddCommand(addCmd)
RootCmd.AddCommand(addCmd)
}

88
cmd/add_test.go Normal file
View File

@@ -0,0 +1,88 @@
package cmd
import (
"bytes"
"testing"
"github.com/spf13/cobra"
)
func TestAddCommand(t *testing.T) {
// Test that the add command is properly configured
if addCmd.Use != "add [hostname]" {
t.Errorf("Expected Use 'add [hostname]', got '%s'", addCmd.Use)
}
if addCmd.Short != "Add a new SSH host configuration" {
t.Errorf("Expected Short description, got '%s'", addCmd.Short)
}
// Test that it accepts maximum 1 argument
err := addCmd.Args(addCmd, []string{"host1", "host2"})
if err == nil {
t.Error("Expected error for too many arguments")
}
// Test that it accepts 0 or 1 argument
err = addCmd.Args(addCmd, []string{})
if err != nil {
t.Errorf("Expected no error for 0 arguments, got %v", err)
}
err = addCmd.Args(addCmd, []string{"hostname"})
if err != nil {
t.Errorf("Expected no error for 1 argument, got %v", err)
}
}
func TestAddCommandRegistration(t *testing.T) {
// Check that add command is registered with root command
found := false
for _, cmd := range RootCmd.Commands() {
if cmd.Name() == "add" {
found = true
break
}
}
if !found {
t.Error("Add command not found in root command")
}
}
func TestAddCommandHelp(t *testing.T) {
// Test help output
cmd := &cobra.Command{}
cmd.AddCommand(addCmd)
buf := new(bytes.Buffer)
cmd.SetOut(buf)
cmd.SetArgs([]string{"add", "--help"})
// This should not return an error for help
err := cmd.Execute()
if err != nil {
t.Errorf("Expected no error for help command, got %v", err)
}
output := buf.String()
if !contains(output, "Add a new SSH host configuration") {
t.Error("Help output should contain command description")
}
}
// Helper function to check if string contains substring
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > len(substr) && (s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
containsSubstring(s, substr))))
}
func containsSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@@ -2,7 +2,8 @@ package cmd
import (
"fmt"
"sshm/internal/ui"
"github.com/Gu1llaum-3/sshm/internal/ui"
"github.com/spf13/cobra"
)
@@ -23,5 +24,5 @@ var editCmd = &cobra.Command{
}
func init() {
rootCmd.AddCommand(editCmd)
RootCmd.AddCommand(editCmd)
}

70
cmd/edit_test.go Normal file
View File

@@ -0,0 +1,70 @@
package cmd
import (
"bytes"
"testing"
"github.com/spf13/cobra"
)
func TestEditCommand(t *testing.T) {
// Test that the edit command is properly configured
if editCmd.Use != "edit <hostname>" {
t.Errorf("Expected Use 'edit <hostname>', got '%s'", editCmd.Use)
}
if editCmd.Short != "Edit an existing SSH host configuration" {
t.Errorf("Expected Short description, got '%s'", editCmd.Short)
}
// Test that it requires exactly 1 argument
err := editCmd.Args(editCmd, []string{})
if err == nil {
t.Error("Expected error for no arguments")
}
err = editCmd.Args(editCmd, []string{"host1", "host2"})
if err == nil {
t.Error("Expected error for too many arguments")
}
err = editCmd.Args(editCmd, []string{"hostname"})
if err != nil {
t.Errorf("Expected no error for 1 argument, got %v", err)
}
}
func TestEditCommandRegistration(t *testing.T) {
// Check that edit command is registered with root command
found := false
for _, cmd := range RootCmd.Commands() {
if cmd.Name() == "edit" {
found = true
break
}
}
if !found {
t.Error("Edit command not found in root command")
}
}
func TestEditCommandHelp(t *testing.T) {
// Test help output
cmd := &cobra.Command{}
cmd.AddCommand(editCmd)
buf := new(bytes.Buffer)
cmd.SetOut(buf)
cmd.SetArgs([]string{"edit", "--help"})
// This should not return an error for help
err := cmd.Execute()
if err != nil {
t.Errorf("Expected no error for help command, got %v", err)
}
output := buf.String()
if !contains(output, "Edit an existing SSH host configuration") {
t.Error("Help output should contain command description")
}
}

28
cmd/move.go Normal file
View File

@@ -0,0 +1,28 @@
package cmd
import (
"fmt"
"github.com/Gu1llaum-3/sshm/internal/ui"
"github.com/spf13/cobra"
)
var moveCmd = &cobra.Command{
Use: "move <hostname>",
Short: "Move an existing SSH host configuration to another config file",
Long: `Move an existing SSH host configuration to another config file with an interactive file selector.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
hostname := args[0]
err := ui.RunMoveForm(hostname, configFile)
if err != nil {
fmt.Printf("Error moving host: %v\n", err)
}
},
}
func init() {
RootCmd.AddCommand(moveCmd)
}

View File

@@ -1,45 +1,57 @@
package cmd
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"strings"
"syscall"
"time"
"sshm/internal/config"
"sshm/internal/ui"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/history"
"github.com/Gu1llaum-3/sshm/internal/ui"
"github.com/Gu1llaum-3/sshm/internal/version"
"github.com/spf13/cobra"
)
// version will be set at build time via -ldflags
var version = "dev"
// AppVersion will be set at build time via -ldflags
var AppVersion = "dev"
// configFile holds the path to the SSH config file
var configFile string
var rootCmd = &cobra.Command{
Use: "sshm",
// RootCmd is the base command when called without any subcommands
var RootCmd = &cobra.Command{
Use: "sshm [host]",
Short: "SSH Manager - A modern SSH connection manager",
Long: `SSHM is a modern SSH manager for your terminal.
Main usage:
Running 'sshm' (without arguments) opens the interactive TUI window to browse, search, and connect to your SSH hosts graphically.
Running 'sshm <host>' connects directly to the specified host and records the connection in your history.
You can also use sshm in CLI mode for direct operations.
You can also use sshm in CLI mode for other operations like adding, editing, or searching hosts.
Hosts are read from your ~/.ssh/config file by default.`,
Version: version,
Run: func(cmd *cobra.Command, args []string) {
Version: AppVersion,
Args: cobra.ArbitraryArgs,
SilenceUsage: true,
SilenceErrors: true, // We'll handle errors ourselves
RunE: func(cmd *cobra.Command, args []string) error {
// If no arguments provided, run interactive mode
if len(args) == 0 {
runInteractiveMode()
return
return nil
}
// If a host name is provided, connect directly
hostName := args[0]
connectToHost(hostName)
return nil
},
}
@@ -85,7 +97,7 @@ func runInteractiveMode() {
}
// Run the interactive TUI
if err := ui.RunInteractiveMode(hosts, configFile); err != nil {
if err := ui.RunInteractiveMode(hosts, configFile, AppVersion); err != nil {
log.Fatalf("Error running interactive mode: %v", err)
}
}
@@ -120,25 +132,88 @@ func connectToHost(hostName string) {
os.Exit(1)
}
// Connect to the host
fmt.Printf("Connecting to %s...\n", hostName)
// Build the SSH command with the appropriate config file
var sshCmd []string
if configFile != "" {
sshCmd = []string{"ssh", "-F", configFile, hostName}
// Record the connection in history
historyManager, err := history.NewHistoryManager()
if err != nil {
// Log the error but don't prevent the connection
fmt.Printf("Warning: Could not initialize connection history: %v\n", err)
} else {
sshCmd = []string{"ssh", hostName}
err = historyManager.RecordConnection(hostName)
if err != nil {
// Log the error but don't prevent the connection
fmt.Printf("Warning: Could not record connection history: %v\n", err)
}
}
// Note: In a real implementation, you'd use exec.Command here
// For now, just print the command that would be executed
fmt.Printf("%s\n", strings.Join(sshCmd, " "))
// Build and execute the SSH command
fmt.Printf("Connecting to %s...\n", hostName)
var sshCmd *exec.Cmd
if configFile != "" {
sshCmd = exec.Command("ssh", "-F", configFile, hostName)
} else {
sshCmd = exec.Command("ssh", hostName)
}
// Set up the command to use the same stdin, stdout, and stderr as the parent process
sshCmd.Stdin = os.Stdin
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
// Execute the SSH command
err = sshCmd.Run()
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
// SSH command failed, exit with the same code
if status, ok := exitError.Sys().(syscall.WaitStatus); ok {
os.Exit(status.ExitStatus())
}
}
fmt.Printf("Error executing SSH command: %v\n", err)
os.Exit(1)
}
}
// getVersionWithUpdateCheck returns a custom version string with update check
func getVersionWithUpdateCheck() string {
versionText := fmt.Sprintf("sshm version %s", AppVersion)
// Check for updates
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
updateInfo, err := version.CheckForUpdates(ctx, AppVersion)
if err != nil {
// Return just version if check fails
return versionText + "\n"
}
if updateInfo != nil && updateInfo.Available {
versionText += fmt.Sprintf("\n🚀 Update available: %s → %s (%s)",
updateInfo.CurrentVer,
updateInfo.LatestVer,
updateInfo.ReleaseURL)
}
return versionText + "\n"
}
// Execute adds all child commands to the root command and sets flags appropriately.
func Execute() {
if err := rootCmd.Execute(); err != nil {
// Custom error handling for unknown commands that might be host names
if err := RootCmd.Execute(); err != nil {
// Check if this is an "unknown command" error and the argument might be a host name
errStr := err.Error()
if strings.Contains(errStr, "unknown command") {
// Extract the command name from the error
parts := strings.Split(errStr, "\"")
if len(parts) >= 2 {
potentialHost := parts[1]
// Try to connect to this as a host
connectToHost(potentialHost)
return
}
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
@@ -146,5 +221,8 @@ func Execute() {
func init() {
// Add the config file flag
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)")
RootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)")
// Set custom version template with update check
RootCmd.SetVersionTemplate(getVersionWithUpdateCheck())
}

145
cmd/root_test.go Normal file
View File

@@ -0,0 +1,145 @@
package cmd
import (
"bytes"
"strings"
"testing"
)
func TestRootCommand(t *testing.T) {
// Test that the root command is properly configured
if RootCmd.Use != "sshm [host]" {
t.Errorf("Expected Use 'sshm [host]', got '%s'", RootCmd.Use)
}
if RootCmd.Short != "SSH Manager - A modern SSH connection manager" {
t.Errorf("Expected Short description, got '%s'", RootCmd.Short)
}
if RootCmd.Version != AppVersion {
t.Errorf("Expected Version '%s', got '%s'", AppVersion, RootCmd.Version)
}
}
func TestRootCommandFlags(t *testing.T) {
// Test that persistent flags are properly configured
flags := RootCmd.PersistentFlags()
// Check config flag
configFlag := flags.Lookup("config")
if configFlag == nil {
t.Error("Expected --config flag to be defined")
return
}
if configFlag.Shorthand != "c" {
t.Errorf("Expected config flag shorthand 'c', got '%s'", configFlag.Shorthand)
}
}
func TestRootCommandSubcommands(t *testing.T) {
// Test that all expected subcommands are registered
// Note: completion and help are automatically added by Cobra and may not always appear in Commands()
expectedCommands := []string{"add", "edit", "search"}
commands := RootCmd.Commands()
commandNames := make(map[string]bool)
for _, cmd := range commands {
commandNames[cmd.Name()] = true
}
for _, expected := range expectedCommands {
if !commandNames[expected] {
t.Errorf("Expected command '%s' not found", expected)
}
}
// Check that we have at least the core commands
if len(commandNames) < 3 {
t.Errorf("Expected at least 3 commands, got %d", len(commandNames))
}
}
func TestRootCommandHelp(t *testing.T) {
// Test help output
buf := new(bytes.Buffer)
RootCmd.SetOut(buf)
RootCmd.SetArgs([]string{"--help"})
// This should not return an error for help
err := RootCmd.Execute()
if err != nil {
t.Errorf("Expected no error for help command, got %v", err)
}
output := buf.String()
if !strings.Contains(output, "modern SSH manager") {
t.Error("Help output should contain command description")
}
if !strings.Contains(output, "Usage:") {
t.Error("Help output should contain usage section")
}
}
func TestRootCommandVersion(t *testing.T) {
// Test that version command executes without error
// Note: Cobra handles version output internally, so we just check for no error
RootCmd.SetArgs([]string{"--version"})
// This should not return an error for version
err := RootCmd.Execute()
if err != nil {
t.Errorf("Expected no error for version command, got %v", err)
}
// Reset args for other tests
RootCmd.SetArgs([]string{})
}
func TestExecuteFunction(t *testing.T) {
// Test that Execute function exists and can be called
// We can't easily test the actual execution without mocking,
// but we can test that the function exists
t.Log("Execute function exists and is accessible")
}
func TestConnectToHostFunction(t *testing.T) {
// Test that connectToHost function exists and can be called
// Note: We can't easily test the actual connection without a valid SSH config
// and without actually connecting to a host, but we can verify the function exists
t.Log("connectToHost function exists and is accessible")
// The function will handle errors internally (like host not found)
// We don't want to actually test the SSH connection in unit tests
}
func TestRunInteractiveModeFunction(t *testing.T) {
// Test that runInteractiveMode function exists
// We can't easily test the actual execution without mocking the UI,
// but we can verify the function signature
t.Log("runInteractiveMode function exists and is accessible")
}
func TestConfigFileVariable(t *testing.T) {
// Test that configFile variable is properly initialized
originalConfigFile := configFile
defer func() { configFile = originalConfigFile }()
// Set config file through flag
RootCmd.SetArgs([]string{"--config", "/tmp/test-config"})
RootCmd.ParseFlags([]string{"--config", "/tmp/test-config"})
// The configFile variable should be updated by the flag parsing
// Note: This test verifies the flag binding works
}
func TestVersionVariable(t *testing.T) {
// Test that version variable has a default value
if AppVersion == "" {
t.Error("AppVersion variable should have a default value")
}
// Test that version is set to "dev" by default
if AppVersion != "dev" {
t.Logf("AppVersion is set to '%s' (expected 'dev' for development)", AppVersion)
}
}

View File

@@ -5,7 +5,7 @@ import (
"os"
"strings"
"sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/spf13/cobra"
)
@@ -235,7 +235,7 @@ func escapeJSON(s string) string {
func init() {
// Add search command to root
rootCmd.AddCommand(searchCmd)
RootCmd.AddCommand(searchCmd)
// Add flags
searchCmd.Flags().StringVarP(&outputFormat, "format", "f", "table", "Output format (table, json, simple)")

120
cmd/search_test.go Normal file
View File

@@ -0,0 +1,120 @@
package cmd
import (
"strings"
"testing"
)
func TestSearchCommand(t *testing.T) {
// Test that the search command is properly configured
if searchCmd.Use != "search [query]" {
t.Errorf("Expected Use 'search [query]', got '%s'", searchCmd.Use)
}
if searchCmd.Short != "Search SSH hosts by name, hostname, or tags" {
t.Errorf("Expected Short description, got '%s'", searchCmd.Short)
}
// Test that it accepts maximum 1 argument
err := searchCmd.Args(searchCmd, []string{"query1", "query2"})
if err == nil {
t.Error("Expected error for too many arguments")
}
// Test that it accepts 0 or 1 argument
err = searchCmd.Args(searchCmd, []string{})
if err != nil {
t.Errorf("Expected no error for 0 arguments, got %v", err)
}
err = searchCmd.Args(searchCmd, []string{"query"})
if err != nil {
t.Errorf("Expected no error for 1 argument, got %v", err)
}
}
func TestSearchCommandRegistration(t *testing.T) {
// Check that search command is registered with root command
found := false
for _, cmd := range RootCmd.Commands() {
if cmd.Name() == "search" {
found = true
break
}
}
if !found {
t.Error("Search command not found in root command")
}
}
func TestSearchCommandFlags(t *testing.T) {
// Test that flags are properly configured
flags := searchCmd.Flags()
// Check format flag
formatFlag := flags.Lookup("format")
if formatFlag == nil {
t.Error("Expected --format flag to be defined")
}
// Check tags flag
tagsFlag := flags.Lookup("tags")
if tagsFlag == nil {
t.Error("Expected --tags flag to be defined")
}
// Check names flag
namesFlag := flags.Lookup("names")
if namesFlag == nil {
t.Error("Expected --names flag to be defined")
}
}
func TestSearchCommandHelp(t *testing.T) {
// Test that the command has the right help properties
// Instead of executing --help, just check the Long description
if searchCmd.Long == "" {
t.Error("Search command should have a Long description")
}
if !strings.Contains(searchCmd.Long, "Search") {
t.Error("Long description should contain information about searching")
}
}
func TestFormatOutput(t *testing.T) {
tests := []struct {
name string
format string
valid bool
}{
{"table format", "table", true},
{"json format", "json", true},
{"simple format", "simple", true},
{"invalid format", "invalid", false},
{"empty format", "", true}, // Should default to table
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
valid := isValidFormat(tt.format)
if valid != tt.valid {
t.Errorf("isValidFormat(%q) = %v, want %v", tt.format, valid, tt.valid)
}
})
}
}
// Helper function to validate format (this would be in the actual search.go)
func isValidFormat(format string) bool {
if format == "" {
return true // Default to table
}
validFormats := []string{"table", "json", "simple"}
for _, valid := range validFormats {
if format == valid {
return true
}
}
return false
}

9
go.mod
View File

@@ -1,4 +1,4 @@
module sshm
module github.com/Gu1llaum-3/sshm
go 1.23.1
@@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.9.1
golang.org/x/crypto v0.41.0
)
require (
@@ -28,7 +29,7 @@ require (
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
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
)

16
go.sum
View File

@@ -49,15 +49,19 @@ 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/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
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/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.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=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
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=

View File

@@ -12,8 +12,28 @@ curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh
**Note:** When using the pipe method, the installer will automatically proceed with installation if SSHM is already installed.
## Windows Installation
### Quick Install (Recommended)
```powershell
irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
```
### Install Options
**Force install without prompts:**
```powershell
iex "& { $(irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1) } -Force"
```
**Custom installation directory:**
```powershell
iex "& { $(irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1) } -InstallDir 'C:\tools'"
```
## Unix/Linux/macOS Advanced Options
**Force install without prompts:**
```bash
FORCE_INSTALL=true bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)"

View File

@@ -74,10 +74,10 @@ downloadBinary() {
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"
# Check if the expected binary exists (no find needed)
EXTRACTED_BINARY="./sshm-${OS}-${ARCH}"
if [ ! -f "$EXTRACTED_BINARY" ]; then
printf "${RED}Could not find extracted binary: $EXTRACTED_BINARY${NC}\n"
exit 1
fi
@@ -88,17 +88,37 @@ downloadBinary() {
install() {
printf "${YELLOW}Installing SSHM...${NC}\n"
# Backup old version if it exists to prevent interference during installation
OLD_BACKUP=""
if [ -f "$EXECUTABLE_PATH" ]; then
OLD_BACKUP="$EXECUTABLE_PATH.backup.$$"
runAsRoot mv "$EXECUTABLE_PATH" "$OLD_BACKUP"
fi
chmod +x "sshm-tmp"
if [ $? -ne 0 ]; then
printf "${RED}Failed to set permissions${NC}\n"
# Restore backup if installation fails
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
fi
exit 1
fi
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
if [ $? -ne 0 ]; then
printf "${RED}Failed to install binary${NC}\n"
# Restore backup if installation fails
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
fi
exit 1
fi
# Clean up backup if installation succeeded
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
runAsRoot rm -f "$OLD_BACKUP"
fi
}
cleanup() {
@@ -161,7 +181,8 @@ main() {
# Show version
printf "${YELLOW}Verifying installation...${NC}\n"
if command -v sshm >/dev/null 2>&1; then
sshm --version
# Use the full path to ensure we're using the newly installed version
"$EXECUTABLE_PATH" --version 2>/dev/null || echo "Version check failed, but installation completed"
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

135
install/windows.ps1 Normal file
View File

@@ -0,0 +1,135 @@
# SSHM Windows Installation Script
# Usage:
# Online: irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
# Local: .\install\windows.ps1 -LocalBinary ".\sshm.exe"
param(
[string]$InstallDir = "$env:LOCALAPPDATA\sshm",
[switch]$Force = $false,
[string]$LocalBinary = ""
)
$ErrorActionPreference = "Stop"
# Colors for output
function Write-ColorOutput($ForegroundColor) {
$fc = $host.UI.RawUI.ForegroundColor
$host.UI.RawUI.ForegroundColor = $ForegroundColor
if ($args) {
Write-Output $args
}
$host.UI.RawUI.ForegroundColor = $fc
}
function Write-Info { Write-ColorOutput Green $args }
function Write-Warning { Write-ColorOutput Yellow $args }
function Write-Error { Write-ColorOutput Red $args }
Write-Info "🚀 Installing SSHM - SSH Manager"
Write-Info ""
# Check if SSHM is already installed
$existingSSHM = Get-Command sshm -ErrorAction SilentlyContinue
if ($existingSSHM -and -not $Force) {
$currentVersion = & sshm --version 2>$null | Select-String "version" | ForEach-Object { $_.ToString().Split()[-1] }
Write-Warning "SSHM is already installed (version: $currentVersion)"
$response = Read-Host "Do you want to continue with the installation? (y/N)"
if ($response -ne "y" -and $response -ne "Y") {
Write-Info "Installation cancelled."
exit 0
}
}
# Detect architecture
$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" }
Write-Info "Detected platform: Windows ($arch)"
# Check if using local binary
if ($LocalBinary -ne "") {
if (-not (Test-Path $LocalBinary)) {
Write-Error "Local binary not found: $LocalBinary"
exit 1
}
Write-Info "Using local binary: $LocalBinary"
$targetPath = "$InstallDir\sshm.exe"
# Create installation directory
if (-not (Test-Path $InstallDir)) {
Write-Info "Creating installation directory: $InstallDir"
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
# Copy local binary
Write-Info "Installing binary to: $targetPath"
Copy-Item -Path $LocalBinary -Destination $targetPath -Force
} else {
# Online installation
Write-Info "Starting online installation..."
# Get latest version
Write-Info "Fetching latest version..."
try {
$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest"
$latestVersion = $latestRelease.tag_name
Write-Info "Target version: $latestVersion"
} catch {
Write-Error "Failed to fetch version information"
exit 1
}
# Download binary
$fileName = "sshm-windows-$arch.zip"
$downloadUrl = "https://github.com/Gu1llaum-3/sshm/releases/download/$latestVersion/$fileName"
$tempFile = "$env:TEMP\$fileName"
Write-Info "Downloading $fileName..."
try {
Invoke-WebRequest -Uri $downloadUrl -OutFile $tempFile
} catch {
Write-Error "Download failed"
exit 1
}
# Create installation directory
if (-not (Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
# Extract archive
Write-Info "Extracting..."
try {
Expand-Archive -Path $tempFile -DestinationPath $env:TEMP -Force
$extractedBinary = "$env:TEMP\sshm-windows-$arch.exe"
$targetPath = "$InstallDir\sshm.exe"
Move-Item -Path $extractedBinary -Destination $targetPath -Force
} catch {
Write-Error "Extraction failed"
exit 1
}
# Clean up
Remove-Item $tempFile -Force
}
# Check PATH
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($userPath -notlike "*$InstallDir*") {
Write-Warning "The directory $InstallDir is not in your PATH."
Write-Info "Adding to user PATH..."
[Environment]::SetEnvironmentVariable("Path", "$userPath;$InstallDir", "User")
Write-Info "Please restart your terminal to use the 'sshm' command."
}
Write-Info ""
Write-Info "✅ SSHM successfully installed to: $targetPath"
Write-Info "You can now use the 'sshm' command!"
# Verify installation
if (Test-Path $targetPath) {
Write-Info ""
Write-Info "Verifying installation..."
& $targetPath --version
}

View File

@@ -0,0 +1,11 @@
//go:build !windows
package config
import "os"
// SetSecureFilePermissions configures secure permissions on Unix systems
func SetSecureFilePermissions(filepath string) error {
// Set file permissions to 0600 (owner read/write only)
return os.Chmod(filepath, 0600)
}

View File

@@ -0,0 +1,24 @@
//go:build windows
package config
import (
"os"
)
// SetSecureFilePermissions configures secure permissions on Windows
func SetSecureFilePermissions(filepath string) error {
// On Windows, file permissions work differently
// We ensure the file is not read-only and has basic permissions
info, err := os.Stat(filepath)
if err != nil {
return err
}
// Ensure the file is not read-only
if info.Mode()&os.ModeType == 0 {
return os.Chmod(filepath, 0600)
}
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
)
@@ -20,14 +21,108 @@ type SSHHost struct {
ProxyJump string
Options string
Tags []string
SourceFile string // Path to the config file where this host is defined
}
// GetDefaultSSHConfigPath returns the default SSH config path for the current platform
func GetDefaultSSHConfigPath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
switch runtime.GOOS {
case "windows":
return filepath.Join(homeDir, ".ssh", "config"), nil
default:
// Linux, macOS, etc.
return filepath.Join(homeDir, ".ssh", "config"), nil
}
}
// GetSSHMConfigDir returns the SSHM config directory
func GetSSHMConfigDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
var configDir string
switch runtime.GOOS {
case "windows":
// Use %APPDATA%/sshm on Windows
appData := os.Getenv("APPDATA")
if appData != "" {
configDir = filepath.Join(appData, "sshm")
} else {
configDir = filepath.Join(homeDir, ".config", "sshm")
}
default:
// Use XDG Base Directory specification
xdgConfigDir := os.Getenv("XDG_CONFIG_HOME")
if xdgConfigDir != "" {
configDir = filepath.Join(xdgConfigDir, "sshm")
} else {
configDir = filepath.Join(homeDir, ".config", "sshm")
}
}
return configDir, nil
}
// GetSSHMBackupDir returns the SSHM backup directory
func GetSSHMBackupDir() (string, error) {
configDir, err := GetSSHMConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "backups"), nil
}
// GetSSHDirectory returns the .ssh directory path
func GetSSHDirectory() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(homeDir, ".ssh"), nil
}
// ensureSSHDirectory creates the .ssh directory with appropriate permissions
func ensureSSHDirectory() error {
sshDir, err := GetSSHDirectory()
if err != nil {
return err
}
if _, err := os.Stat(sshDir); os.IsNotExist(err) {
// 0700 provides owner-only access across platforms
return os.MkdirAll(sshDir, 0700)
}
return nil
}
// configMutex protects SSH config file operations from race conditions
var configMutex sync.Mutex
// backupConfig creates a backup of the SSH config file
// backupConfig creates a backup of the SSH config file in ~/.config/sshm/backups/
func backupConfig(configPath string) error {
backupPath := configPath + ".backup"
// Get backup directory and ensure it exists
backupDir, err := GetSSHMBackupDir()
if err != nil {
return fmt.Errorf("failed to get backup directory: %w", err)
}
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %w", err)
}
// Create simple backup filename (overwrites previous backup)
filename := filepath.Base(configPath)
backupPath := filepath.Join(backupDir, filename+".backup")
// Copy file
src, err := os.Open(configPath)
if err != nil {
return err
@@ -40,38 +135,64 @@ func backupConfig(configPath string) error {
}
defer dst.Close()
_, err = io.Copy(dst, src)
if _, err = io.Copy(dst, src); err != nil {
return err
}
// Set appropriate permissions
return os.Chmod(backupPath, 0600)
}
// ParseSSHConfig parses the SSH config file and returns the list of hosts
func ParseSSHConfig() ([]SSHHost, error) {
homeDir, err := os.UserHomeDir()
configPath, err := GetDefaultSSHConfigPath()
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) {
return parseSSHConfigFileWithProcessedFiles(configPath, make(map[string]bool))
}
// parseSSHConfigFileWithProcessedFiles parses SSH config with include support
func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[string]bool) ([]SSHHost, error) {
// Resolve absolute path to prevent infinite recursion
absPath, err := filepath.Abs(configPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", configPath, err)
}
// Check for circular includes
if processedFiles[absPath] {
return []SSHHost{}, nil // Skip already processed files silently
}
processedFiles[absPath] = true
// 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 {
// Only create the main config file, not included files
if absPath == getMainConfigPath() {
// Ensure .ssh directory exists with proper permissions
if err := ensureSSHDirectory(); 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
// Set secure permissions on the config file
if err := SetSecureFilePermissions(configPath); err != nil {
return nil, fmt.Errorf("failed to set secure permissions: %w", err)
}
}
// File doesn't exist, return empty host list
return []SSHHost{}, nil
}
@@ -125,16 +246,31 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
value := strings.Join(parts[1:], " ")
switch key {
case "include":
// Handle Include directive
includeHosts, err := processIncludeDirective(value, configPath, processedFiles)
if err != nil {
// Don't fail the entire parse if include fails, just skip it
continue
}
hosts = append(hosts, includeHosts...)
case "host":
// New host, save previous one if it exists
if currentHost != nil {
hosts = append(hosts, *currentHost)
}
// Skip hosts with wildcards (*, ?) as they are typically patterns, not actual hosts
if strings.ContainsAny(value, "*?") {
currentHost = nil
pendingTags = nil
continue
}
// Create new host
currentHost = &SSHHost{
Name: value,
Port: "22", // Default port
Tags: pendingTags, // Assign pending tags to this host
SourceFile: absPath, // Track which file this host comes from
}
// Clear pending tags for next host
pendingTags = nil
@@ -179,13 +315,71 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
return hosts, scanner.Err()
}
// processIncludeDirective processes an Include directive and returns hosts from included files
func processIncludeDirective(pattern string, baseConfigPath string, processedFiles map[string]bool) ([]SSHHost, error) {
// Expand tilde to home directory
if strings.HasPrefix(pattern, "~") {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
pattern = filepath.Join(homeDir, pattern[1:])
}
// If pattern is not absolute, make it relative to the base config directory
if !filepath.IsAbs(pattern) {
baseDir := filepath.Dir(baseConfigPath)
pattern = filepath.Join(baseDir, pattern)
}
// Use glob to find matching files
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("failed to glob pattern %s: %w", pattern, err)
}
var allHosts []SSHHost
for _, match := range matches {
// Skip directories
if info, err := os.Stat(match); err == nil && info.IsDir() {
continue
}
// Skip backup files created by sshm (*.backup)
if strings.HasSuffix(match, ".backup") {
continue
}
// Skip markdown files (*.md)
if strings.HasSuffix(match, ".md") {
continue
}
// Recursively parse the included file
hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles)
if err != nil {
// Skip files that can't be parsed rather than failing completely
continue
}
allHosts = append(allHosts, hosts...)
}
return allHosts, nil
}
// getMainConfigPath returns the main SSH config path for comparison
func getMainConfigPath() string {
configPath, _ := GetDefaultSSHConfigPath()
absPath, _ := filepath.Abs(configPath)
return absPath
}
// AddSSHHost adds a new SSH host to the config file
func AddSSHHost(host SSHHost) error {
homeDir, err := os.UserHomeDir()
configPath, err := GetDefaultSSHConfigPath()
if err != nil {
return err
}
configPath := filepath.Join(homeDir, ".ssh", "config")
return AddSSHHostToFile(host, configPath)
}
@@ -359,17 +553,40 @@ func HostExists(hostName string) (bool, error) {
// HostExistsInFile checks if a host exists in a specific config file
func HostExistsInFile(hostName string, configPath string) (bool, error) {
hosts, err := ParseSSHConfigFile(configPath)
if err != nil {
return false, err
// Parse only the specific file, not its includes
return HostExistsInSpecificFile(hostName, configPath)
}
for _, host := range hosts {
if host.Name == hostName {
// HostExistsInSpecificFile checks if a host exists in a specific file only (no includes)
func HostExistsInSpecificFile(hostName string, configPath string) (bool, error) {
file, err := os.Open(configPath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Check for Host declaration
if strings.HasPrefix(strings.ToLower(line), "host ") {
// Extract host names (can be multiple hosts on one line)
hostPart := strings.TrimSpace(line[5:]) // Remove "host "
hostNames := strings.Fields(hostPart)
for _, name := range hostNames {
if name == hostName {
return true, nil
}
}
return false, nil
}
}
return false, scanner.Err()
}
// GetSSHHost retrieves a specific host configuration by name
@@ -404,12 +621,7 @@ func GetSSHHostFromFile(hostName string, configPath string) (*SSHHost, error) {
// UpdateSSHHost updates an existing SSH host configuration
func UpdateSSHHost(oldName string, newHost SSHHost) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
configPath := filepath.Join(homeDir, ".ssh", "config")
return UpdateSSHHostInFile(oldName, newHost, configPath)
return UpdateSSHHostV2(oldName, newHost)
}
// UpdateSSHHostInFile updates an existing SSH host configuration in a specific file
@@ -564,12 +776,7 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
// DeleteSSHHost removes an SSH host configuration from the config file
func DeleteSSHHost(hostName string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
configPath := filepath.Join(homeDir, ".ssh", "config")
return DeleteSSHHostFromFile(hostName, configPath)
return DeleteSSHHostV2(hostName)
}
// DeleteSSHHostFromFile deletes an SSH host from a specific config file
@@ -653,3 +860,179 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
newContent := strings.Join(newLines, "\n")
return os.WriteFile(configPath, []byte(newContent), 0600)
}
// FindHostInAllConfigs finds a host in all configuration files and returns the host with its source file
func FindHostInAllConfigs(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 in any configuration file", hostName)
}
// GetAllConfigFiles returns all SSH config files (main + included files)
func GetAllConfigFiles() ([]string, error) {
configPath, err := GetDefaultSSHConfigPath()
if err != nil {
return nil, err
}
processedFiles := make(map[string]bool)
_, _ = parseSSHConfigFileWithProcessedFiles(configPath, processedFiles)
files := make([]string, 0, len(processedFiles))
for file := range processedFiles {
files = append(files, file)
}
return files, nil
}
// GetAllConfigFilesFromBase returns all SSH config files starting from a specific base config file
func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) {
if baseConfigPath == "" {
// Fallback to default behavior
return GetAllConfigFiles()
}
processedFiles := make(map[string]bool)
_, _ = parseSSHConfigFileWithProcessedFiles(baseConfigPath, processedFiles)
files := make([]string, 0, len(processedFiles))
for file := range processedFiles {
files = append(files, file)
}
return files, nil
} // UpdateSSHHostV2 updates an existing SSH host configuration, searching in all config files
func UpdateSSHHostV2(oldName string, newHost SSHHost) error {
// Find the host to determine which file it's in
existingHost, err := FindHostInAllConfigs(oldName)
if err != nil {
return err
}
// Update the host in its source file
newHost.SourceFile = existingHost.SourceFile
return UpdateSSHHostInFile(oldName, newHost, existingHost.SourceFile)
}
// DeleteSSHHostV2 removes an SSH host configuration, searching in all config files
func DeleteSSHHostV2(hostName string) error {
// Find the host to determine which file it's in
existingHost, err := FindHostInAllConfigs(hostName)
if err != nil {
return err
}
// Delete the host from its source file
return DeleteSSHHostFromFile(hostName, existingHost.SourceFile)
}
// AddSSHHostWithFileSelection adds a new SSH host to a user-specified config file
func AddSSHHostWithFileSelection(host SSHHost, targetFile string) error {
if targetFile == "" {
// Use default file if none specified
return AddSSHHost(host)
}
return AddSSHHostToFile(host, targetFile)
}
// GetIncludedConfigFiles returns a list of config files that can be used for adding hosts
func GetIncludedConfigFiles() ([]string, error) {
allFiles, err := GetAllConfigFiles()
if err != nil {
return nil, err
}
// Filter out files that don't exist or can't be written to
var writableFiles []string
mainConfig, err := GetDefaultSSHConfigPath()
if err == nil {
writableFiles = append(writableFiles, mainConfig)
}
for _, file := range allFiles {
if file == mainConfig {
continue // Already added
}
// Check if file exists and is writable
if info, err := os.Stat(file); err == nil && !info.IsDir() {
writableFiles = append(writableFiles, file)
}
}
return writableFiles, nil
}
// MoveHostToFile moves an SSH host from its current config file to a target config file
func MoveHostToFile(hostName string, targetConfigFile string) error {
// Find the host in all configs to get its current location and data
host, err := FindHostInAllConfigs(hostName)
if err != nil {
return err
}
// Check if the target file is different from the current source file
if host.SourceFile == targetConfigFile {
return fmt.Errorf("host '%s' is already in the target config file '%s'", hostName, targetConfigFile)
}
// First, add the host to the target config file
err = AddSSHHostToFile(*host, targetConfigFile)
if err != nil {
return fmt.Errorf("failed to add host to target file: %v", err)
}
// Then, remove the host from its current source file
err = DeleteSSHHostFromFile(hostName, host.SourceFile)
if err != nil {
// If removal fails, we should try to rollback the addition, but for simplicity
// we'll just return the error. In a production environment, you might want
// to implement a proper rollback mechanism.
return fmt.Errorf("failed to remove host from source file: %v", err)
}
return nil
}
// GetConfigFilesExcludingCurrent returns all config files except the one containing the specified host
func GetConfigFilesExcludingCurrent(hostName string, baseConfigFile string) ([]string, error) {
// Get all config files
var allFiles []string
var err error
if baseConfigFile != "" {
allFiles, err = GetAllConfigFilesFromBase(baseConfigFile)
} else {
allFiles, err = GetAllConfigFiles()
}
if err != nil {
return nil, err
}
// Find the host to get its current source file
host, err := FindHostInAllConfigs(hostName)
if err != nil {
return nil, err
}
// Filter out the current source file
var filteredFiles []string
for _, file := range allFiles {
if file != host.SourceFile {
filteredFiles = append(filteredFiles, file)
}
}
return filteredFiles, nil
}

989
internal/config/ssh_test.go Normal file
View File

@@ -0,0 +1,989 @@
package config
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestGetDefaultSSHConfigPath(t *testing.T) {
tests := []struct {
name string
goos string
expected string
}{
{"Linux", "linux", ".ssh/config"},
{"macOS", "darwin", ".ssh/config"},
{"Windows", "windows", ".ssh/config"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Save original GOOS
originalGOOS := runtime.GOOS
defer func() {
// Note: We can't actually change runtime.GOOS at runtime
// This test verifies the function logic with the current OS
_ = originalGOOS
}()
configPath, err := GetDefaultSSHConfigPath()
if err != nil {
t.Fatalf("GetDefaultSSHConfigPath() error = %v", err)
}
if !strings.HasSuffix(configPath, tt.expected) {
t.Errorf("Expected path to end with %q, got %q", tt.expected, configPath)
}
// Verify the path uses the correct separator for current OS
expectedSeparator := string(filepath.Separator)
if !strings.Contains(configPath, expectedSeparator) && len(configPath) > len(tt.expected) {
t.Errorf("Path should use OS-specific separator %q, got %q", expectedSeparator, configPath)
}
})
}
}
func TestGetSSHDirectory(t *testing.T) {
sshDir, err := GetSSHDirectory()
if err != nil {
t.Fatalf("GetSSHDirectory() error = %v", err)
}
if !strings.HasSuffix(sshDir, ".ssh") {
t.Errorf("Expected directory to end with .ssh, got %q", sshDir)
}
// Verify the path uses the correct separator for current OS
expectedSeparator := string(filepath.Separator)
if !strings.Contains(sshDir, expectedSeparator) && len(sshDir) > 4 {
t.Errorf("Path should use OS-specific separator %q, got %q", expectedSeparator, sshDir)
}
}
func TestEnsureSSHDirectory(t *testing.T) {
// This test just ensures the function doesn't panic
// and returns without error when .ssh directory already exists
err := ensureSSHDirectory()
if err != nil {
t.Fatalf("ensureSSHDirectory() error = %v", err)
}
}
func TestParseSSHConfigWithInclude(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create main config file
mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host
HostName example.com
User mainuser
Include included.conf
Include subdir/*
Host another-host
HostName another.example.com
User anotheruser
`
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create main config: %v", err)
}
// Create included file
includedConfig := filepath.Join(tempDir, "included.conf")
includedConfigContent := `Host included-host
HostName included.example.com
User includeduser
Port 2222
`
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create included config: %v", err)
}
// Create subdirectory with another config file
subDir := filepath.Join(tempDir, "subdir")
err = os.MkdirAll(subDir, 0700)
if err != nil {
t.Fatalf("Failed to create subdir: %v", err)
}
subConfig := filepath.Join(subDir, "sub.conf")
subConfigContent := `Host sub-host
HostName sub.example.com
User subuser
IdentityFile ~/.ssh/sub_key
`
err = os.WriteFile(subConfig, []byte(subConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create sub config: %v", err)
}
// Parse the main config file
hosts, err := ParseSSHConfigFile(mainConfig)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Check that we got all expected hosts
expectedHosts := map[string]struct{}{
"main-host": {},
"included-host": {},
"sub-host": {},
"another-host": {},
}
if len(hosts) != len(expectedHosts) {
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
}
for _, host := range hosts {
if _, exists := expectedHosts[host.Name]; !exists {
t.Errorf("Unexpected host found: %s", host.Name)
}
delete(expectedHosts, host.Name)
// Validate specific host properties
switch host.Name {
case "main-host":
if host.Hostname != "example.com" || host.User != "mainuser" {
t.Errorf("main-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
}
if host.SourceFile != mainConfig {
t.Errorf("main-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
}
case "included-host":
if host.Hostname != "included.example.com" || host.User != "includeduser" || host.Port != "2222" {
t.Errorf("included-host properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
}
if host.SourceFile != includedConfig {
t.Errorf("included-host SourceFile incorrect: expected=%s, got=%s", includedConfig, host.SourceFile)
}
case "sub-host":
if host.Hostname != "sub.example.com" || host.User != "subuser" || host.Identity != "~/.ssh/sub_key" {
t.Errorf("sub-host properties incorrect: hostname=%s, user=%s, identity=%s", host.Hostname, host.User, host.Identity)
}
if host.SourceFile != subConfig {
t.Errorf("sub-host SourceFile incorrect: expected=%s, got=%s", subConfig, host.SourceFile)
}
case "another-host":
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
t.Errorf("another-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
}
if host.SourceFile != mainConfig {
t.Errorf("another-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
}
}
}
// Check that all expected hosts were found
if len(expectedHosts) > 0 {
var missing []string
for host := range expectedHosts {
missing = append(missing, host)
}
t.Errorf("Missing hosts: %v", missing)
}
}
func TestParseSSHConfigWithCircularInclude(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create config1 that includes config2
config1 := filepath.Join(tempDir, "config1")
config1Content := `Host host1
HostName example1.com
Include config2
`
err := os.WriteFile(config1, []byte(config1Content), 0600)
if err != nil {
t.Fatalf("Failed to create config1: %v", err)
}
// Create config2 that includes config1 (circular)
config2 := filepath.Join(tempDir, "config2")
config2Content := `Host host2
HostName example2.com
Include config1
`
err = os.WriteFile(config2, []byte(config2Content), 0600)
if err != nil {
t.Fatalf("Failed to create config2: %v", err)
}
// Parse the config file - should not cause infinite recursion
hosts, err := ParseSSHConfigFile(config1)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should get both hosts exactly once
expectedHosts := map[string]bool{
"host1": false,
"host2": false,
}
for _, host := range hosts {
if _, exists := expectedHosts[host.Name]; !exists {
t.Errorf("Unexpected host found: %s", host.Name)
} else {
if expectedHosts[host.Name] {
t.Errorf("Host %s found multiple times", host.Name)
}
expectedHosts[host.Name] = true
}
}
// Check all hosts were found
for hostName, found := range expectedHosts {
if !found {
t.Errorf("Host %s not found", hostName)
}
}
}
func TestParseSSHConfigWithNonExistentInclude(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create main config file with non-existent include
mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host
HostName example.com
Include non-existent-file.conf
Host another-host
HostName another.example.com
`
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create main config: %v", err)
}
// Parse should succeed and ignore the non-existent include
hosts, err := ParseSSHConfigFile(mainConfig)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should get the hosts that exist, ignoring the failed include
if len(hosts) != 2 {
t.Errorf("Expected 2 hosts, got %d", len(hosts))
}
hostNames := make(map[string]bool)
for _, host := range hosts {
hostNames[host.Name] = true
}
if !hostNames["main-host"] || !hostNames["another-host"] {
t.Errorf("Expected main-host and another-host, got: %v", hostNames)
}
}
func TestParseSSHConfigWithWildcardHosts(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create config file with wildcard hosts
configFile := filepath.Join(tempDir, "config")
configContent := `# Wildcard patterns should be ignored
Host *.example.com
User defaultuser
IdentityFile ~/.ssh/id_rsa
Host server-*
Port 2222
Host *
ServerAliveInterval 60
# Real hosts should be included
Host real-server
HostName real.example.com
User realuser
Host another-real-server
HostName another.example.com
User anotheruser
`
err := os.WriteFile(configFile, []byte(configContent), 0600)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
// Parse the config file
hosts, err := ParseSSHConfigFile(configFile)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should only get real hosts, not wildcard patterns
expectedHosts := map[string]bool{
"real-server": false,
"another-real-server": false,
}
if len(hosts) != len(expectedHosts) {
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
for _, host := range hosts {
t.Logf("Found host: %s", host.Name)
}
}
for _, host := range hosts {
if _, expected := expectedHosts[host.Name]; !expected {
t.Errorf("Unexpected host found: %s", host.Name)
} else {
expectedHosts[host.Name] = true
}
}
// Check that all expected hosts were found
for hostName, found := range expectedHosts {
if !found {
t.Errorf("Expected host %s not found", hostName)
}
}
// Verify host properties
for _, host := range hosts {
switch host.Name {
case "real-server":
if host.Hostname != "real.example.com" || host.User != "realuser" {
t.Errorf("real-server properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
}
case "another-real-server":
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
t.Errorf("another-real-server properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
}
}
}
}
func TestParseSSHConfigExcludesBackupFiles(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create main config file with include pattern
mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host
HostName example.com
Include *.conf
`
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create main config: %v", err)
}
// Create a regular config file
regularConfig := filepath.Join(tempDir, "regular.conf")
regularConfigContent := `Host regular-host
HostName regular.example.com
`
err = os.WriteFile(regularConfig, []byte(regularConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create regular config: %v", err)
}
// Create a backup file that should be excluded
backupConfig := filepath.Join(tempDir, "regular.conf.backup")
backupConfigContent := `Host backup-host
HostName backup.example.com
`
err = os.WriteFile(backupConfig, []byte(backupConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create backup config: %v", err)
}
// Parse the config file
hosts, err := ParseSSHConfigFile(mainConfig)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should only get main-host and regular-host, not backup-host
expectedHosts := map[string]bool{
"main-host": false,
"regular-host": false,
}
if len(hosts) != len(expectedHosts) {
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
for _, host := range hosts {
t.Logf("Found host: %s", host.Name)
}
}
for _, host := range hosts {
if _, expected := expectedHosts[host.Name]; !expected {
t.Errorf("Unexpected host found: %s (backup files should be excluded)", host.Name)
} else {
expectedHosts[host.Name] = true
}
}
// Check that backup-host was not included
for _, host := range hosts {
if host.Name == "backup-host" {
t.Error("backup-host should not be included (backup files should be excluded)")
}
}
}
func TestBackupConfigToSSHMDirectory(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Override the home directory for this test
originalHome := os.Getenv("HOME")
if originalHome == "" {
originalHome = os.Getenv("USERPROFILE") // Windows
}
// Set test home directory
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
// Create a test SSH config file
sshDir := filepath.Join(tempDir, ".ssh")
err := os.MkdirAll(sshDir, 0700)
if err != nil {
t.Fatalf("Failed to create .ssh directory: %v", err)
}
configPath := filepath.Join(sshDir, "config")
configContent := `Host test-host
HostName test.example.com
User testuser
`
err = os.WriteFile(configPath, []byte(configContent), 0600)
if err != nil {
t.Fatalf("Failed to create config file: %v", err)
}
// Test backup creation
err = backupConfig(configPath)
if err != nil {
t.Fatalf("backupConfig() error = %v", err)
}
// Verify backup directory was created
backupDir, err := GetSSHMBackupDir()
if err != nil {
t.Fatalf("GetSSHMBackupDir() error = %v", err)
}
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
t.Errorf("Backup directory was not created: %s", backupDir)
}
// Verify backup file was created
files, err := os.ReadDir(backupDir)
if err != nil {
t.Fatalf("Failed to read backup directory: %v", err)
}
if len(files) != 1 {
t.Errorf("Expected 1 backup file, got %d", len(files))
}
if len(files) > 0 {
backupFile := files[0]
expectedName := "config.backup"
if backupFile.Name() != expectedName {
t.Errorf("Backup file has unexpected name: got %s, want %s", backupFile.Name(), expectedName)
}
// Verify backup content
backupContent, err := os.ReadFile(filepath.Join(backupDir, backupFile.Name()))
if err != nil {
t.Fatalf("Failed to read backup file: %v", err)
}
if string(backupContent) != configContent {
t.Errorf("Backup content doesn't match original")
}
}
// Test that subsequent backups overwrite the previous one
newConfigContent := `Host test-host-updated
HostName updated.example.com
User updateduser
`
err = os.WriteFile(configPath, []byte(newConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to update config file: %v", err)
}
// Create second backup
err = backupConfig(configPath)
if err != nil {
t.Fatalf("Second backupConfig() error = %v", err)
}
// Verify still only one backup file exists
files, err = os.ReadDir(backupDir)
if err != nil {
t.Fatalf("Failed to read backup directory after second backup: %v", err)
}
if len(files) != 1 {
t.Errorf("Expected still 1 backup file after overwrite, got %d", len(files))
}
// Verify backup content was updated
if len(files) > 0 {
backupContent, err := os.ReadFile(filepath.Join(backupDir, files[0].Name()))
if err != nil {
t.Fatalf("Failed to read updated backup file: %v", err)
}
if string(backupContent) != newConfigContent {
t.Errorf("Updated backup content doesn't match new config content")
}
}
}
func TestFindHostInAllConfigs(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create main config file
mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host
HostName example.com
Include included.conf
`
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create main config: %v", err)
}
// Create included config file
includedConfig := filepath.Join(tempDir, "included.conf")
includedConfigContent := `Host included-host
HostName included.example.com
User includeduser
`
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create included config: %v", err)
}
// Test finding host from main config
host, err := GetSSHHostFromFile("main-host", mainConfig)
if err != nil {
t.Fatalf("GetSSHHostFromFile() error = %v", err)
}
if host.Name != "main-host" || host.Hostname != "example.com" {
t.Errorf("main-host not found correctly: name=%s, hostname=%s", host.Name, host.Hostname)
}
if host.SourceFile != mainConfig {
t.Errorf("main-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
}
// Test finding host from included config
// Note: This tests the full parsing with includes
hosts, err := ParseSSHConfigFile(mainConfig)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
var includedHost *SSHHost
for _, h := range hosts {
if h.Name == "included-host" {
includedHost = &h
break
}
}
if includedHost == nil {
t.Fatal("included-host not found")
}
if includedHost.Hostname != "included.example.com" || includedHost.User != "includeduser" {
t.Errorf("included-host properties incorrect: hostname=%s, user=%s", includedHost.Hostname, includedHost.User)
}
if includedHost.SourceFile != includedConfig {
t.Errorf("included-host SourceFile incorrect: expected=%s, got=%s", includedConfig, includedHost.SourceFile)
}
}
func TestGetAllConfigFiles(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create main config file
mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host
HostName example.com
Include included.conf
Include subdir/*.conf
`
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create main config: %v", err)
}
// Create included config file
includedConfig := filepath.Join(tempDir, "included.conf")
err = os.WriteFile(includedConfig, []byte("Host included-host\n HostName included.example.com\n"), 0600)
if err != nil {
t.Fatalf("Failed to create included config: %v", err)
}
// Create subdirectory with config files
subDir := filepath.Join(tempDir, "subdir")
err = os.MkdirAll(subDir, 0700)
if err != nil {
t.Fatalf("Failed to create subdir: %v", err)
}
subConfig := filepath.Join(subDir, "sub.conf")
err = os.WriteFile(subConfig, []byte("Host sub-host\n HostName sub.example.com\n"), 0600)
if err != nil {
t.Fatalf("Failed to create sub config: %v", err)
}
// Parse to populate the processed files map
_, err = ParseSSHConfigFile(mainConfig)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Note: GetAllConfigFiles() uses a fresh parse, so we test it indirectly
// by checking that all files are found during parsing
hosts, err := ParseSSHConfigFile(mainConfig)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Check that hosts from all files are found
sourceFiles := make(map[string]bool)
for _, host := range hosts {
sourceFiles[host.SourceFile] = true
}
expectedFiles := []string{mainConfig, includedConfig, subConfig}
for _, expectedFile := range expectedFiles {
if !sourceFiles[expectedFile] {
t.Errorf("Expected config file not found in SourceFile: %s", expectedFile)
}
}
}
func TestGetAllConfigFilesFromBase(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create main config file
mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host
HostName example.com
Include included.conf
`
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create main config: %v", err)
}
// Create included config file
includedConfig := filepath.Join(tempDir, "included.conf")
includedConfigContent := `Host included-host
HostName included.example.com
Include subdir/*.conf
`
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create included config: %v", err)
}
// Create subdirectory with config files
subDir := filepath.Join(tempDir, "subdir")
err = os.MkdirAll(subDir, 0700)
if err != nil {
t.Fatalf("Failed to create subdir: %v", err)
}
subConfig := filepath.Join(subDir, "sub.conf")
err = os.WriteFile(subConfig, []byte("Host sub-host\n HostName sub.example.com\n"), 0600)
if err != nil {
t.Fatalf("Failed to create sub config: %v", err)
}
// Create an isolated config file that should not be included
isolatedConfig := filepath.Join(tempDir, "isolated.conf")
err = os.WriteFile(isolatedConfig, []byte("Host isolated-host\n HostName isolated.example.com\n"), 0600)
if err != nil {
t.Fatalf("Failed to create isolated config: %v", err)
}
// Test GetAllConfigFilesFromBase with main config as base
files, err := GetAllConfigFilesFromBase(mainConfig)
if err != nil {
t.Fatalf("GetAllConfigFilesFromBase() error = %v", err)
}
// Should find main config, included config, and sub config, but not isolated config
expectedFiles := map[string]bool{
mainConfig: false,
includedConfig: false,
subConfig: false,
}
if len(files) != len(expectedFiles) {
t.Errorf("Expected %d config files, got %d", len(expectedFiles), len(files))
for i, file := range files {
t.Logf("Found file %d: %s", i+1, file)
}
}
for _, file := range files {
if _, expected := expectedFiles[file]; expected {
expectedFiles[file] = true
} else if file == isolatedConfig {
t.Errorf("Isolated config file should not be included: %s", file)
} else {
t.Logf("Unexpected file found: %s", file)
}
}
// Check that all expected files were found
for file, found := range expectedFiles {
if !found {
t.Errorf("Expected config file not found: %s", file)
}
}
// Test GetAllConfigFilesFromBase with isolated config as base (should only return itself)
isolatedFiles, err := GetAllConfigFilesFromBase(isolatedConfig)
if err != nil {
t.Fatalf("GetAllConfigFilesFromBase() error = %v", err)
}
if len(isolatedFiles) != 1 || isolatedFiles[0] != isolatedConfig {
t.Errorf("Expected only isolated config file, got: %v", isolatedFiles)
}
// Test with empty base config file path (should fallback to default behavior)
defaultFiles, err := GetAllConfigFilesFromBase("")
if err != nil {
t.Fatalf("GetAllConfigFilesFromBase('') error = %v", err)
}
// Should behave like GetAllConfigFiles()
allFiles, err := GetAllConfigFiles()
if err != nil {
t.Fatalf("GetAllConfigFiles() error = %v", err)
}
if len(defaultFiles) != len(allFiles) {
t.Errorf("GetAllConfigFilesFromBase('') should behave like GetAllConfigFiles(). Got %d vs %d files", len(defaultFiles), len(allFiles))
}
}
func TestHostExistsInSpecificFile(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create main config file
mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host
HostName example.com
User mainuser
Include included.conf
Host another-host
HostName another.example.com
User anotheruser
`
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create main config: %v", err)
}
// Create included config file
includedConfig := filepath.Join(tempDir, "included.conf")
includedConfigContent := `Host included-host
HostName included.example.com
User includeduser
`
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create included config: %v", err)
}
// Test that host exists in main config file (should ignore includes)
exists, err := HostExistsInSpecificFile("main-host", mainConfig)
if err != nil {
t.Fatalf("HostExistsInSpecificFile() error = %v", err)
}
if !exists {
t.Error("main-host should exist in main config file")
}
// Test that host from included file does NOT exist in main config file
exists, err = HostExistsInSpecificFile("included-host", mainConfig)
if err != nil {
t.Fatalf("HostExistsInSpecificFile() error = %v", err)
}
if exists {
t.Error("included-host should NOT exist in main config file (should ignore includes)")
}
// Test that host exists in included config file
exists, err = HostExistsInSpecificFile("included-host", includedConfig)
if err != nil {
t.Fatalf("HostExistsInSpecificFile() error = %v", err)
}
if !exists {
t.Error("included-host should exist in included config file")
}
// Test non-existent host
exists, err = HostExistsInSpecificFile("non-existent", mainConfig)
if err != nil {
t.Fatalf("HostExistsInSpecificFile() error = %v", err)
}
if exists {
t.Error("non-existent host should not exist")
}
// Test with non-existent file
exists, err = HostExistsInSpecificFile("any-host", "/non/existent/file")
if err != nil {
t.Fatalf("HostExistsInSpecificFile() should not return error for non-existent file: %v", err)
}
if exists {
t.Error("non-existent file should not contain any hosts")
}
}
func TestGetConfigFilesExcludingCurrent(t *testing.T) {
// This test verifies the function works when SSH config is properly set up
// Since GetConfigFilesExcludingCurrent depends on FindHostInAllConfigs which uses the default SSH config,
// we'll test the function more directly by creating a temporary SSH config setup
// Skip this test if we can't access SSH config directory
_, err := GetSSHDirectory()
if err != nil {
t.Skipf("Skipping test: cannot get SSH directory: %v", err)
}
// Check if SSH config exists
defaultConfigPath, err := GetDefaultSSHConfigPath()
if err != nil {
t.Skipf("Skipping test: cannot get default SSH config path: %v", err)
}
if _, err := os.Stat(defaultConfigPath); os.IsNotExist(err) {
t.Skipf("Skipping test: SSH config file does not exist at %s", defaultConfigPath)
}
// Test that the function returns something for a hypothetical host
// We can't guarantee specific hosts exist, so we test the function doesn't crash
_, err = GetConfigFilesExcludingCurrent("test-host-that-probably-does-not-exist", defaultConfigPath)
if err == nil {
t.Log("GetConfigFilesExcludingCurrent() succeeded for non-existent host (expected)")
} else if strings.Contains(err.Error(), "not found") {
t.Log("GetConfigFilesExcludingCurrent() correctly reported host not found")
} else {
t.Fatalf("GetConfigFilesExcludingCurrent() unexpected error = %v", err)
}
// Test with valid SSH config directory
if err == nil {
t.Log("GetConfigFilesExcludingCurrent() function is working correctly")
}
}
func TestMoveHostToFile(t *testing.T) {
// This test verifies the MoveHostToFile function works when SSH config is properly set up
// Since MoveHostToFile depends on FindHostInAllConfigs which uses the default SSH config,
// we'll test the error handling and basic function behavior
// Check if SSH config exists
defaultConfigPath, err := GetDefaultSSHConfigPath()
if err != nil {
t.Skipf("Skipping test: cannot get default SSH config path: %v", err)
}
if _, err := os.Stat(defaultConfigPath); os.IsNotExist(err) {
t.Skipf("Skipping test: SSH config file does not exist at %s", defaultConfigPath)
}
// Create a temporary destination config file
tempDir := t.TempDir()
destConfig := filepath.Join(tempDir, "dest.conf")
destConfigContent := `Host dest-host
HostName dest.example.com
User destuser
`
err = os.WriteFile(destConfig, []byte(destConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create dest config: %v", err)
}
// Test moving non-existent host (should return error)
err = MoveHostToFile("non-existent-host-12345", destConfig)
if err == nil {
t.Error("MoveHostToFile() should return error for non-existent host")
} else if !strings.Contains(err.Error(), "not found") {
t.Errorf("Expected 'not found' error, got: %v", err)
}
// Test moving to non-existent file (should return error)
err = MoveHostToFile("any-host", "/non/existent/file")
if err == nil {
t.Error("MoveHostToFile() should return error for non-existent destination file")
}
// Verify that the HostExistsInSpecificFile function works correctly
// This is a component that MoveHostToFile uses
exists, err := HostExistsInSpecificFile("dest-host", destConfig)
if err != nil {
t.Fatalf("HostExistsInSpecificFile() error = %v", err)
}
if !exists {
t.Error("dest-host should exist in destination config file")
}
// Test that the component functions work for the move operation
t.Log("MoveHostToFile() error handling works correctly")
}

View File

@@ -0,0 +1,212 @@
package connectivity
import (
"context"
"net"
"github.com/Gu1llaum-3/sshm/internal/config"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
// PingStatus represents the connectivity status of an SSH host
type PingStatus int
const (
StatusUnknown PingStatus = iota
StatusConnecting
StatusOnline
StatusOffline
)
func (s PingStatus) String() string {
switch s {
case StatusUnknown:
return "unknown"
case StatusConnecting:
return "connecting"
case StatusOnline:
return "online"
case StatusOffline:
return "offline"
}
return "unknown"
}
// HostPingResult represents the result of pinging a host
type HostPingResult struct {
HostName string
Status PingStatus
Error error
Duration time.Duration
}
// PingManager manages SSH connectivity checks for multiple hosts
type PingManager struct {
results map[string]*HostPingResult
mutex sync.RWMutex
timeout time.Duration
}
// NewPingManager creates a new ping manager with the specified timeout
func NewPingManager(timeout time.Duration) *PingManager {
return &PingManager{
results: make(map[string]*HostPingResult),
timeout: timeout,
}
}
// GetStatus returns the current status for a host
func (pm *PingManager) GetStatus(hostName string) PingStatus {
pm.mutex.RLock()
defer pm.mutex.RUnlock()
if result, exists := pm.results[hostName]; exists {
return result.Status
}
return StatusUnknown
}
// GetResult returns the complete result for a host
func (pm *PingManager) GetResult(hostName string) (*HostPingResult, bool) {
pm.mutex.RLock()
defer pm.mutex.RUnlock()
result, exists := pm.results[hostName]
return result, exists
}
// updateStatus updates the status for a host
func (pm *PingManager) updateStatus(hostName string, status PingStatus, err error, duration time.Duration) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
pm.results[hostName] = &HostPingResult{
HostName: hostName,
Status: status,
Error: err,
Duration: duration,
}
}
// PingHost performs an SSH connectivity check for a single host
func (pm *PingManager) PingHost(ctx context.Context, host config.SSHHost) *HostPingResult {
start := time.Now()
// Mark as connecting
pm.updateStatus(host.Name, StatusConnecting, nil, 0)
// Determine the actual hostname and port
hostname := host.Hostname
if hostname == "" {
hostname = host.Name
}
port := host.Port
if port == "" {
port = "22"
}
// Create context with timeout
pingCtx, cancel := context.WithTimeout(ctx, pm.timeout)
defer cancel()
// Try to establish a TCP connection first (faster than SSH handshake)
dialer := &net.Dialer{}
conn, err := dialer.DialContext(pingCtx, "tcp", net.JoinHostPort(hostname, port))
if err != nil {
duration := time.Since(start)
pm.updateStatus(host.Name, StatusOffline, err, duration)
return &HostPingResult{
HostName: host.Name,
Status: StatusOffline,
Error: err,
Duration: duration,
}
}
defer conn.Close()
// If TCP connection succeeds, try SSH handshake
sshConfig := &ssh.ClientConfig{
User: host.User,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // For ping purposes only
Timeout: time.Second * 2, // Short timeout for handshake
}
// We don't need to authenticate, just check if SSH is responding
sshConn, _, _, err := ssh.NewClientConn(conn, net.JoinHostPort(hostname, port), sshConfig)
if sshConn != nil {
sshConn.Close()
}
duration := time.Since(start)
// Even if SSH handshake fails, if we got a TCP connection, consider it online
// This handles cases where authentication fails but the host is reachable
status := StatusOnline
if err != nil && isConnectionError(err) {
status = StatusOffline
}
pm.updateStatus(host.Name, status, err, duration)
return &HostPingResult{
HostName: host.Name,
Status: status,
Error: err,
Duration: duration,
}
}
// PingAllHosts pings all hosts concurrently and returns a channel of results
func (pm *PingManager) PingAllHosts(ctx context.Context, hosts []config.SSHHost) <-chan *HostPingResult {
resultChan := make(chan *HostPingResult, len(hosts))
var wg sync.WaitGroup
for _, host := range hosts {
wg.Add(1)
go func(h config.SSHHost) {
defer wg.Done()
result := pm.PingHost(ctx, h)
select {
case resultChan <- result:
case <-ctx.Done():
return
}
}(host)
}
// Close the channel when all goroutines are done
go func() {
wg.Wait()
close(resultChan)
}()
return resultChan
}
// isConnectionError determines if an error is a connection-related error
func isConnectionError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
connectionErrors := []string{
"connection refused",
"no route to host",
"network is unreachable",
"timeout",
"connection timed out",
}
for _, connErr := range connectionErrors {
if strings.Contains(strings.ToLower(errStr), connErr) {
return true
}
}
return false
}

View File

@@ -0,0 +1,144 @@
package connectivity
import (
"context"
"testing"
"time"
"github.com/Gu1llaum-3/sshm/internal/config"
)
func TestNewPingManager(t *testing.T) {
pm := NewPingManager(5 * time.Second)
if pm == nil {
t.Error("NewPingManager() returned nil")
}
if pm.results == nil {
t.Error("PingManager.results map not initialized")
}
}
func TestPingManager_PingHost(t *testing.T) {
pm := NewPingManager(1 * time.Second)
ctx := context.Background()
// Test ping method exists and doesn't panic
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}
result := pm.PingHost(ctx, host)
if result == nil {
t.Error("Expected ping result to be returned")
}
// Test with invalid host
invalidHost := config.SSHHost{Name: "invalid", Hostname: "invalid.host.12345", Port: "22"}
result = pm.PingHost(ctx, invalidHost)
if result == nil {
t.Error("Expected ping result to be returned even for invalid host")
}
}
func TestPingManager_GetStatus(t *testing.T) {
pm := NewPingManager(1 * time.Second)
// Test unknown host
status := pm.GetStatus("unknown.host")
if status != StatusUnknown {
t.Errorf("Expected StatusUnknown for unknown host, got %v", status)
}
// Test after ping
ctx := context.Background()
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}
pm.PingHost(ctx, host)
status = pm.GetStatus("test")
if status == StatusUnknown {
t.Error("Expected status to be set after ping")
}
}
func TestPingManager_PingMultipleHosts(t *testing.T) {
pm := NewPingManager(1 * time.Second)
hosts := []config.SSHHost{
{Name: "localhost", Hostname: "127.0.0.1", Port: "22"},
{Name: "invalid", Hostname: "invalid.host.12345", Port: "22"},
}
ctx := context.Background()
// Ping each host individually
for _, host := range hosts {
result := pm.PingHost(ctx, host)
if result == nil {
t.Errorf("Expected ping result for host %s", host.Name)
}
// Check that status was set
status := pm.GetStatus(host.Name)
if status == StatusUnknown {
t.Errorf("Expected status to be set for host %s after ping", host.Name)
}
}
}
func TestPingManager_GetResult(t *testing.T) {
pm := NewPingManager(1 * time.Second)
ctx := context.Background()
// Test getting result for unknown host
result, exists := pm.GetResult("unknown")
if exists || result != nil {
t.Error("Expected no result for unknown host")
}
// Test after ping
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}
pm.PingHost(ctx, host)
result, exists = pm.GetResult("test")
if !exists || result == nil {
t.Error("Expected result to exist after ping")
}
if result.HostName != "test" {
t.Errorf("Expected hostname 'test', got '%s'", result.HostName)
}
}
func TestPingStatus_String(t *testing.T) {
tests := []struct {
status PingStatus
expected string
}{
{StatusUnknown, "unknown"},
{StatusConnecting, "connecting"},
{StatusOnline, "online"},
{StatusOffline, "offline"},
{PingStatus(999), "unknown"}, // Invalid status
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
if got := tt.status.String(); got != tt.expected {
t.Errorf("PingStatus.String() = %v, want %v", got, tt.expected)
}
})
}
}
func TestPingHost_Basic(t *testing.T) {
// Test that the ping functionality exists
pm := NewPingManager(1 * time.Second)
ctx := context.Background()
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}
// Just ensure the function doesn't panic
result := pm.PingHost(ctx, host)
if result == nil {
t.Error("Expected ping result to be returned")
}
// Test that status is set
status := pm.GetStatus("test")
if status == StatusUnknown {
t.Error("Expected status to be set after ping attempt")
}
}

View File

@@ -7,7 +7,7 @@ import (
"sort"
"time"
"sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/config"
)
// ConnectionHistory represents the history of SSH connections
@@ -15,11 +15,21 @@ type ConnectionHistory struct {
Connections map[string]ConnectionInfo `json:"connections"`
}
// PortForwardConfig stores port forwarding configuration
type PortForwardConfig struct {
Type string `json:"type"` // "local", "remote", "dynamic"
LocalPort string `json:"local_port"`
RemoteHost string `json:"remote_host"`
RemotePort string `json:"remote_port"`
BindAddress string `json:"bind_address"`
}
// ConnectionInfo stores information about a specific connection
type ConnectionInfo struct {
HostName string `json:"host_name"`
LastConnect time.Time `json:"last_connect"`
ConnectCount int `json:"connect_count"`
PortForwarding *PortForwardConfig `json:"port_forwarding,omitempty"`
}
// HistoryManager manages the connection history
@@ -30,12 +40,23 @@ type HistoryManager struct {
// NewHistoryManager creates a new history manager
func NewHistoryManager() (*HistoryManager, error) {
homeDir, err := os.UserHomeDir()
configDir, err := config.GetSSHMConfigDir()
if err != nil {
return nil, err
}
historyPath := filepath.Join(homeDir, ".ssh", "sshm_history.json")
// Ensure config dir exists
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, err
}
historyPath := filepath.Join(configDir, "sshm_history.json")
// Migration: check if old history file exists and migrate it
if err := migrateOldHistoryFile(historyPath); err != nil {
// Don't fail if migration fails, just log it
// In a production environment, you might want to log this properly
}
hm := &HistoryManager{
historyPath: historyPath,
@@ -54,6 +75,46 @@ func NewHistoryManager() (*HistoryManager, error) {
return hm, nil
}
// migrateOldHistoryFile migrates the old history file from ~/.ssh to ~/.config/sshm
// TODO: Remove this migration logic in v2.0.0 (introduced in v1.6.0)
func migrateOldHistoryFile(newHistoryPath string) error {
// Check if new file already exists, skip migration
if _, err := os.Stat(newHistoryPath); err == nil {
return nil // New file exists, no migration needed
}
// Get old history file path - use same logic as SSH config location
sshDir, err := config.GetSSHDirectory()
if err != nil {
return err
}
oldHistoryPath := filepath.Join(sshDir, "sshm_history.json")
// Check if old file exists
if _, err := os.Stat(oldHistoryPath); os.IsNotExist(err) {
return nil // Old file doesn't exist, nothing to migrate
}
// Read old file
data, err := os.ReadFile(oldHistoryPath)
if err != nil {
return err
}
// Write to new location
if err := os.WriteFile(newHistoryPath, data, 0644); err != nil {
return err
}
// Remove old file only if write was successful
if err := os.Remove(oldHistoryPath); err != nil {
// Don't fail if we can't remove the old file
// The migration was successful even if cleanup failed
}
return nil
}
// loadHistory loads the connection history from the JSON file
func (hm *HistoryManager) loadHistory() error {
data, err := os.ReadFile(hm.historyPath)
@@ -206,3 +267,42 @@ func (hm *HistoryManager) GetAllConnectionsInfo() []ConnectionInfo {
return connections
}
// RecordPortForwarding saves port forwarding configuration for a host
func (hm *HistoryManager) RecordPortForwarding(hostName, forwardType, localPort, remoteHost, remotePort, bindAddress string) error {
now := time.Now()
portForwardConfig := &PortForwardConfig{
Type: forwardType,
LocalPort: localPort,
RemoteHost: remoteHost,
RemotePort: remotePort,
BindAddress: bindAddress,
}
if conn, exists := hm.history.Connections[hostName]; exists {
// Update existing connection
conn.LastConnect = now
conn.ConnectCount++
conn.PortForwarding = portForwardConfig
hm.history.Connections[hostName] = conn
} else {
// Create new connection record
hm.history.Connections[hostName] = ConnectionInfo{
HostName: hostName,
LastConnect: now,
ConnectCount: 1,
PortForwarding: portForwardConfig,
}
}
return hm.saveHistory()
}
// GetPortForwardingConfig retrieves the last used port forwarding configuration for a host
func (hm *HistoryManager) GetPortForwardingConfig(hostName string) *PortForwardConfig {
if conn, exists := hm.history.Connections[hostName]; exists {
return conn.PortForwarding
}
return nil
}

View File

@@ -0,0 +1,164 @@
package history
import (
"os"
"path/filepath"
"testing"
"time"
)
// createTestHistoryManager creates a history manager with a temporary file for testing
func createTestHistoryManager(t *testing.T) *HistoryManager {
// Create temporary directory
tempDir := t.TempDir()
historyPath := filepath.Join(tempDir, "test_sshm_history.json")
hm := &HistoryManager{
historyPath: historyPath,
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
}
return hm
}
func TestNewHistoryManager(t *testing.T) {
hm, err := NewHistoryManager()
if err != nil {
t.Fatalf("NewHistoryManager() error = %v", err)
}
if hm == nil {
t.Fatal("NewHistoryManager() returned nil")
}
if hm.historyPath == "" {
t.Error("Expected historyPath to be set")
}
}
func TestHistoryManager_RecordConnection(t *testing.T) {
hm := createTestHistoryManager(t)
// Add a connection
err := hm.RecordConnection("testhost")
if err != nil {
t.Errorf("RecordConnection() error = %v", err)
}
// Check that the connection was added
lastUsed, exists := hm.GetLastConnectionTime("testhost")
if !exists || lastUsed.IsZero() {
t.Error("Expected connection to be recorded")
}
}
func TestHistoryManager_GetLastConnectionTime(t *testing.T) {
hm := createTestHistoryManager(t)
// Test with no connections
lastUsed, exists := hm.GetLastConnectionTime("nonexistent-testhost")
if exists || !lastUsed.IsZero() {
t.Error("Expected no connection for non-existent host")
}
// Add a connection
err := hm.RecordConnection("testhost")
if err != nil {
t.Errorf("RecordConnection() error = %v", err)
}
// Test with existing connection
lastUsed, exists = hm.GetLastConnectionTime("testhost")
if !exists || lastUsed.IsZero() {
t.Error("Expected non-zero time for existing host")
}
// Check that the time is recent (within last minute)
if time.Since(lastUsed) > time.Minute {
t.Error("Last used time seems too old")
}
}
func TestHistoryManager_GetConnectionCount(t *testing.T) {
hm := createTestHistoryManager(t)
// Add same host multiple times
for i := 0; i < 3; i++ {
err := hm.RecordConnection("testhost-count")
if err != nil {
t.Errorf("RecordConnection() error = %v", err)
}
time.Sleep(1 * time.Millisecond)
}
// Should have correct count
count := hm.GetConnectionCount("testhost-count")
if count != 3 {
t.Errorf("Expected connection count 3, got %d", count)
}
}
func TestMigrateOldHistoryFile(t *testing.T) {
// This test verifies that migration doesn't fail when called
// The actual migration logic will be tested in integration tests
tempDir := t.TempDir()
newHistoryPath := filepath.Join(tempDir, "sshm_history.json")
// Test that migration works when no old file exists (common case)
if err := migrateOldHistoryFile(newHistoryPath); err != nil {
t.Errorf("migrateOldHistoryFile() with no old file error = %v", err)
}
// Test that migration skips when new file already exists
if err := os.WriteFile(newHistoryPath, []byte(`{"connections":{}}`), 0644); err != nil {
t.Fatalf("Failed to write new history file: %v", err)
}
if err := migrateOldHistoryFile(newHistoryPath); err != nil {
t.Errorf("migrateOldHistoryFile() with existing new file error = %v", err)
}
// File should be unchanged
data, err := os.ReadFile(newHistoryPath)
if err != nil {
t.Errorf("Failed to read new file: %v", err)
}
if string(data) != `{"connections":{}}` {
t.Error("New file was modified when it shouldn't have been")
}
}
func TestMigrateOldHistoryFile_NoOldFile(t *testing.T) {
// Test migration when no old file exists
tempDir := t.TempDir()
newHistoryPath := filepath.Join(tempDir, "sshm_history.json")
// Should not return error when old file doesn't exist
if err := migrateOldHistoryFile(newHistoryPath); err != nil {
t.Errorf("migrateOldHistoryFile() with no old file error = %v", err)
}
}
func TestMigrateOldHistoryFile_NewFileExists(t *testing.T) {
// Test migration when new file already exists (should skip migration)
tempDir := t.TempDir()
newHistoryPath := filepath.Join(tempDir, "sshm_history.json")
// Create new file first
if err := os.WriteFile(newHistoryPath, []byte(`{"connections":{}}`), 0644); err != nil {
t.Fatalf("Failed to write new history file: %v", err)
}
// Migration should skip when new file exists
if err := migrateOldHistoryFile(newHistoryPath); err != nil {
t.Errorf("migrateOldHistoryFile() with existing new file error = %v", err)
}
// New file should be unchanged
data, err := os.ReadFile(newHistoryPath)
if err != nil {
t.Errorf("Failed to read new file: %v", err)
}
if string(data) != `{"connections":{}}` {
t.Error("New file was modified when it shouldn't have been")
}
}

View File

@@ -0,0 +1,183 @@
package history
import (
"os"
"path/filepath"
"testing"
)
func TestPortForwardingHistory(t *testing.T) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "sshm_test_*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create history manager with temp directory
historyPath := filepath.Join(tempDir, "test_history.json")
hm := &HistoryManager{
historyPath: historyPath,
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
}
hostName := "test-server"
// Test recording port forwarding configuration
err = hm.RecordPortForwarding(hostName, "local", "8080", "localhost", "80", "127.0.0.1")
if err != nil {
t.Fatalf("Failed to record port forwarding: %v", err)
}
// Test retrieving port forwarding configuration
config := hm.GetPortForwardingConfig(hostName)
if config == nil {
t.Fatalf("Expected port forwarding config to exist")
}
// Verify the saved configuration
if config.Type != "local" {
t.Errorf("Expected Type 'local', got %s", config.Type)
}
if config.LocalPort != "8080" {
t.Errorf("Expected LocalPort '8080', got %s", config.LocalPort)
}
if config.RemoteHost != "localhost" {
t.Errorf("Expected RemoteHost 'localhost', got %s", config.RemoteHost)
}
if config.RemotePort != "80" {
t.Errorf("Expected RemotePort '80', got %s", config.RemotePort)
}
if config.BindAddress != "127.0.0.1" {
t.Errorf("Expected BindAddress '127.0.0.1', got %s", config.BindAddress)
}
// Test updating configuration with different values
err = hm.RecordPortForwarding(hostName, "remote", "3000", "app-server", "8000", "")
if err != nil {
t.Fatalf("Failed to record updated port forwarding: %v", err)
}
// Verify the updated configuration
config = hm.GetPortForwardingConfig(hostName)
if config == nil {
t.Fatalf("Expected port forwarding config to exist after update")
}
if config.Type != "remote" {
t.Errorf("Expected updated Type 'remote', got %s", config.Type)
}
if config.LocalPort != "3000" {
t.Errorf("Expected updated LocalPort '3000', got %s", config.LocalPort)
}
if config.RemoteHost != "app-server" {
t.Errorf("Expected updated RemoteHost 'app-server', got %s", config.RemoteHost)
}
if config.RemotePort != "8000" {
t.Errorf("Expected updated RemotePort '8000', got %s", config.RemotePort)
}
if config.BindAddress != "" {
t.Errorf("Expected updated BindAddress to be empty, got %s", config.BindAddress)
}
// Test dynamic forwarding
err = hm.RecordPortForwarding(hostName, "dynamic", "1080", "", "", "0.0.0.0")
if err != nil {
t.Fatalf("Failed to record dynamic port forwarding: %v", err)
}
config = hm.GetPortForwardingConfig(hostName)
if config == nil {
t.Fatalf("Expected port forwarding config to exist for dynamic forwarding")
}
if config.Type != "dynamic" {
t.Errorf("Expected Type 'dynamic', got %s", config.Type)
}
if config.LocalPort != "1080" {
t.Errorf("Expected LocalPort '1080', got %s", config.LocalPort)
}
if config.RemoteHost != "" {
t.Errorf("Expected RemoteHost to be empty for dynamic forwarding, got %s", config.RemoteHost)
}
if config.RemotePort != "" {
t.Errorf("Expected RemotePort to be empty for dynamic forwarding, got %s", config.RemotePort)
}
if config.BindAddress != "0.0.0.0" {
t.Errorf("Expected BindAddress '0.0.0.0', got %s", config.BindAddress)
}
}
func TestPortForwardingHistoryPersistence(t *testing.T) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "sshm_test_*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
historyPath := filepath.Join(tempDir, "test_history.json")
// Create first history manager and record data
hm1 := &HistoryManager{
historyPath: historyPath,
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
}
hostName := "persistent-server"
err = hm1.RecordPortForwarding(hostName, "local", "9090", "db-server", "5432", "")
if err != nil {
t.Fatalf("Failed to record port forwarding: %v", err)
}
// Create second history manager and load data
hm2 := &HistoryManager{
historyPath: historyPath,
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
}
err = hm2.loadHistory()
if err != nil {
t.Fatalf("Failed to load history: %v", err)
}
// Verify the loaded configuration
config := hm2.GetPortForwardingConfig(hostName)
if config == nil {
t.Fatalf("Expected port forwarding config to be loaded from file")
}
if config.Type != "local" {
t.Errorf("Expected loaded Type 'local', got %s", config.Type)
}
if config.LocalPort != "9090" {
t.Errorf("Expected loaded LocalPort '9090', got %s", config.LocalPort)
}
if config.RemoteHost != "db-server" {
t.Errorf("Expected loaded RemoteHost 'db-server', got %s", config.RemoteHost)
}
if config.RemotePort != "5432" {
t.Errorf("Expected loaded RemotePort '5432', got %s", config.RemotePort)
}
}
func TestGetPortForwardingConfigNonExistent(t *testing.T) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "sshm_test_*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
historyPath := filepath.Join(tempDir, "test_history.json")
hm := &HistoryManager{
historyPath: historyPath,
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
}
// Test getting configuration for non-existent host
config := hm.GetPortForwardingConfig("non-existent-host")
if config != nil {
t.Errorf("Expected nil config for non-existent host, got %+v", config)
}
}

View File

@@ -4,8 +4,8 @@ import (
"os"
"os/user"
"path/filepath"
"sshm/internal/config"
"sshm/internal/validation"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/validation"
"strings"
"github.com/charmbracelet/bubbles/textinput"

View File

@@ -1,12 +1,13 @@
package ui
import (
"sshm/internal/config"
"sshm/internal/validation"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/validation"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type editFormModel struct {
@@ -16,6 +17,7 @@ type editFormModel struct {
success bool
styles Styles
originalName string
host *config.SSHHost // Store the original host with SourceFile
width int
height int
configFile string
@@ -102,6 +104,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
inputs: inputs,
focused: nameInput,
originalName: hostName,
host: host,
configFile: configFile,
styles: styles,
width: width,
@@ -201,6 +204,24 @@ func (m *editFormModel) View() string {
var b strings.Builder
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration"))
b.WriteString("\n")
// Show source file information
if m.host != nil && m.host.SourceFile != "" {
b.WriteString("\n") // Ligne d'espace avant Config file
// Style for "Config file:" label in primary color
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00ADD8")). // Primary color
Bold(true)
// Style for the file path in white
pathStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
configInfo := labelStyle.Render("Config file: ") + pathStyle.Render(formatConfigFile(m.host.SourceFile))
b.WriteString(configInfo)
}
b.WriteString("\n\n")
fields := []string{

View File

@@ -0,0 +1,162 @@
package ui
import (
"fmt"
"path/filepath"
"github.com/Gu1llaum-3/sshm/internal/config"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type fileSelectorModel struct {
files []string // Chemins absolus des fichiers
displayNames []string // Noms d'affichage conviviaux
selected int
styles Styles
width int
height int
title string
}
type fileSelectorMsg struct {
selectedFile string
cancelled bool
}
// NewFileSelector creates a new file selector for choosing config files
func NewFileSelector(title string, styles Styles, width, height int) (*fileSelectorModel, error) {
files, err := config.GetAllConfigFiles()
if err != nil {
return nil, err
}
return newFileSelectorFromFiles(title, styles, width, height, files)
}
// NewFileSelectorFromBase creates a new file selector starting from a specific base config file
func NewFileSelectorFromBase(title string, styles Styles, width, height int, baseConfigFile string) (*fileSelectorModel, error) {
var files []string
var err error
if baseConfigFile != "" {
files, err = config.GetAllConfigFilesFromBase(baseConfigFile)
} else {
files, err = config.GetAllConfigFiles()
}
if err != nil {
return nil, err
}
return newFileSelectorFromFiles(title, styles, width, height, files)
}
// newFileSelectorFromFiles creates a file selector from a list of files
func newFileSelectorFromFiles(title string, styles Styles, width, height int, files []string) (*fileSelectorModel, error) {
// Convert absolute paths to more user-friendly names
var displayNames []string
homeDir, _ := config.GetSSHDirectory()
for _, file := range files {
// Check if it's the main config file
mainConfig, _ := config.GetDefaultSSHConfigPath()
if file == mainConfig {
displayNames = append(displayNames, "Main SSH Config (~/.ssh/config)")
} else {
// Try to make path relative to home/.ssh/
if strings.HasPrefix(file, homeDir) {
relPath, err := filepath.Rel(homeDir, file)
if err == nil {
displayNames = append(displayNames, fmt.Sprintf("~/.ssh/%s", relPath))
} else {
displayNames = append(displayNames, file)
}
} else {
displayNames = append(displayNames, file)
}
}
}
return &fileSelectorModel{
files: files,
displayNames: displayNames,
selected: 0,
styles: styles,
width: width,
height: height,
title: title,
}, nil
}
func (m *fileSelectorModel) Init() tea.Cmd {
return nil
}
func (m *fileSelectorModel) Update(msg tea.Msg) (*fileSelectorModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.styles = NewStyles(m.width)
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, func() tea.Msg {
return fileSelectorMsg{cancelled: true}
}
case "enter":
selectedFile := ""
if m.selected < len(m.files) {
selectedFile = m.files[m.selected]
}
return m, func() tea.Msg {
return fileSelectorMsg{selectedFile: selectedFile}
}
case "up", "k":
if m.selected > 0 {
m.selected--
}
case "down", "j":
if m.selected < len(m.files)-1 {
m.selected++
}
}
}
return m, nil
}
func (m *fileSelectorModel) View() string {
var b strings.Builder
b.WriteString(m.styles.FormTitle.Render(m.title))
b.WriteString("\n\n")
if len(m.files) == 0 {
b.WriteString(m.styles.Error.Render("No SSH config files found."))
b.WriteString("\n\n")
b.WriteString(m.styles.FormHelp.Render("Esc: cancel"))
return b.String()
}
for i, displayName := range m.displayNames {
if i == m.selected {
b.WriteString(m.styles.Selected.Render(fmt.Sprintf("▶ %s", displayName)))
} else {
b.WriteString(fmt.Sprintf(" %s", displayName))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(m.styles.FormHelp.Render("↑/↓: navigate • Enter: select • Esc: cancel"))
return b.String()
}

134
internal/ui/help_form.go Normal file
View File

@@ -0,0 +1,134 @@
package ui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type helpModel struct {
styles Styles
width int
height int
}
// helpCloseMsg is sent when the help window is closed
type helpCloseMsg struct{}
// NewHelpForm creates a new help form model
func NewHelpForm(styles Styles, width, height int) *helpModel {
return &helpModel{
styles: styles,
width: width,
height: height,
}
}
func (m *helpModel) Init() tea.Cmd {
return nil
}
func (m *helpModel) Update(msg tea.Msg) (*helpModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "q", "h", "enter", "ctrl+c":
return m, func() tea.Msg { return helpCloseMsg{} }
}
}
return m, nil
}
func (m *helpModel) View() string {
// Title
title := m.styles.Header.Render("📖 SSHM - Commands")
// Create two columns of commands for better visual organization
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
m.styles.FocusedLabel.Render("Navigation & Connection"),
"",
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("⏎ "),
m.styles.HelpText.Render("connect to selected host")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("i "),
m.styles.HelpText.Render("show host information")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("/ "),
m.styles.HelpText.Render("search hosts")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("Tab "),
m.styles.HelpText.Render("switch focus")),
"",
m.styles.FocusedLabel.Render("Host Management"),
"",
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("a "),
m.styles.HelpText.Render("add new host")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("e "),
m.styles.HelpText.Render("edit selected host")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("m "),
m.styles.HelpText.Render("move host to another config")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("d "),
m.styles.HelpText.Render("delete selected host")),
)
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
m.styles.FocusedLabel.Render("Advanced Features"),
"",
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("p "),
m.styles.HelpText.Render("ping all hosts")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("f "),
m.styles.HelpText.Render("setup port forwarding")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("s "),
m.styles.HelpText.Render("cycle sort modes")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("n "),
m.styles.HelpText.Render("sort by name")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("r "),
m.styles.HelpText.Render("sort by recent connection")),
"",
m.styles.FocusedLabel.Render("System"),
"",
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("h "),
m.styles.HelpText.Render("show this help")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("q "),
m.styles.HelpText.Render("quit application")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("ESC "),
m.styles.HelpText.Render("exit current view")),
)
// Join the two columns side by side
columns := lipgloss.JoinHorizontal(lipgloss.Top,
leftColumn,
" ", // spacing between columns
rightColumn,
)
// Create the main content
content := lipgloss.JoinVertical(lipgloss.Center,
title,
"",
columns,
"",
m.styles.HelpText.Render("Press ESC, h, q or Enter to close"),
)
// Center the help window
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
m.styles.FormContainer.Render(content),
)
}

228
internal/ui/info_form.go Normal file
View File

@@ -0,0 +1,228 @@
package ui
import (
"fmt"
"github.com/Gu1llaum-3/sshm/internal/config"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type infoFormModel struct {
host *config.SSHHost
styles Styles
width int
height int
configFile string
hostName string
}
// Messages for communication with parent model
type infoFormEditMsg struct {
hostName string
}
type infoFormCancelMsg struct{}
// NewInfoForm creates a new info form model for displaying host details in read-only mode
func NewInfoForm(hostName string, styles Styles, width, height int, configFile string) (*infoFormModel, error) {
// Get the existing host configuration
var host *config.SSHHost
var err error
if configFile != "" {
host, err = config.GetSSHHostFromFile(hostName, configFile)
} else {
host, err = config.GetSSHHost(hostName)
}
if err != nil {
return nil, err
}
return &infoFormModel{
host: host,
hostName: hostName,
configFile: configFile,
styles: styles,
width: width,
height: height,
}, nil
}
func (m *infoFormModel) Init() tea.Cmd {
return nil
}
func (m *infoFormModel) Update(msg tea.Msg) (*infoFormModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.styles = NewStyles(m.width)
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc", "q":
return m, func() tea.Msg { return infoFormCancelMsg{} }
case "e", "enter":
// Switch to edit mode
return m, func() tea.Msg { return infoFormEditMsg{hostName: m.hostName} }
}
}
return m, nil
}
func (m *infoFormModel) View() string {
var b strings.Builder
// Title
title := fmt.Sprintf("SSH Host Information: %s", m.host.Name)
b.WriteString(m.styles.FormTitle.Render(title))
b.WriteString("\n\n")
// Create info sections with consistent formatting
sections := []struct {
label string
value string
}{
{"Host Name", m.host.Name},
{"Config File", formatConfigFile(m.host.SourceFile)},
{"Hostname/IP", m.host.Hostname},
{"User", formatOptionalValue(m.host.User)},
{"Port", formatOptionalValue(m.host.Port)},
{"Identity File", formatOptionalValue(m.host.Identity)},
{"ProxyJump", formatOptionalValue(m.host.ProxyJump)},
{"SSH Options", formatSSHOptions(m.host.Options)},
{"Tags", formatTags(m.host.Tags)},
}
// Render each section
for _, section := range sections {
// Label style
labelStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("39")). // Bright blue
Width(15).
AlignHorizontal(lipgloss.Right)
// Value style
valueStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("255")) // White
// If value is empty or default, use a muted style
if section.value == "Not set" || section.value == "22" && section.label == "Port" {
valueStyle = valueStyle.Foreground(lipgloss.Color("243")) // Gray
}
line := lipgloss.JoinHorizontal(
lipgloss.Top,
labelStyle.Render(section.label+":"),
" ",
valueStyle.Render(section.value),
)
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString("\n")
// Action instructions
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("243")).
Italic(true)
b.WriteString(helpStyle.Render("Actions:"))
b.WriteString("\n")
actionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("120")). // Green
Bold(true)
b.WriteString(" ")
b.WriteString(actionStyle.Render("e/Enter"))
b.WriteString(helpStyle.Render(" - Switch to edit mode"))
b.WriteString("\n")
b.WriteString(" ")
b.WriteString(actionStyle.Render("q/Esc"))
b.WriteString(helpStyle.Render(" - Return to host list"))
// Wrap in a border for better visual separation
content := b.String()
borderStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("39")).
Padding(1).
Margin(1)
// Center the info window
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
borderStyle.Render(content),
)
}
// Helper functions for formatting values
func formatOptionalValue(value string) string {
if value == "" {
return "Not set"
}
return value
}
func formatSSHOptions(options string) string {
if options == "" {
return "Not set"
}
return options
}
func formatTags(tags []string) string {
if len(tags) == 0 {
return "Not set"
}
return strings.Join(tags, ", ")
}
// Standalone wrapper for info form (for testing or standalone use)
type standaloneInfoForm struct {
*infoFormModel
}
func (m standaloneInfoForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case infoFormCancelMsg:
return m, tea.Quit
case infoFormEditMsg:
// For standalone mode, just quit - parent should handle edit transition
return m, tea.Quit
}
newForm, cmd := m.infoFormModel.Update(msg)
m.infoFormModel = newForm
return m, cmd
}
// RunInfoForm provides a standalone info form for testing
func RunInfoForm(hostName string, configFile string) error {
styles := NewStyles(80)
infoForm, err := NewInfoForm(hostName, styles, 80, 24, configFile)
if err != nil {
return err
}
m := standaloneInfoForm{infoForm}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err = p.Run()
return err
}

View File

@@ -1,8 +1,10 @@
package ui
import (
"sshm/internal/config"
"sshm/internal/history"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/connectivity"
"github.com/Gu1llaum-3/sshm/internal/history"
"github.com/Gu1llaum-3/sshm/internal/version"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
@@ -35,8 +37,35 @@ const (
ViewList ViewMode = iota
ViewAdd
ViewEdit
ViewMove
ViewInfo
ViewPortForward
ViewHelp
ViewFileSelector
)
// PortForwardType defines the type of port forwarding
type PortForwardType int
const (
LocalForward PortForwardType = iota
RemoteForward
DynamicForward
)
func (p PortForwardType) String() string {
switch p {
case LocalForward:
return "Local (-L)"
case RemoteForward:
return "Remote (-R)"
case DynamicForward:
return "Dynamic (-D)"
default:
return "Local (-L)"
}
}
// Model represents the state of the user interface
type Model struct {
table table.Model
@@ -47,13 +76,23 @@ type Model struct {
deleteMode bool
deleteHost string
historyManager *history.HistoryManager
pingManager *connectivity.PingManager
sortMode SortMode
configFile string // Path to the SSH config file
// Version update information
updateInfo *version.UpdateInfo
currentVersion string
// View management
viewMode ViewMode
addForm *addFormModel
editForm *editFormModel
moveForm *moveFormModel
infoForm *infoFormModel
portForwardForm *portForwardModel
helpForm *helpModel
fileSelectorForm *fileSelectorModel
// Terminal size and styles
width int

188
internal/ui/move_form.go Normal file
View File

@@ -0,0 +1,188 @@
package ui
import (
"fmt"
"github.com/Gu1llaum-3/sshm/internal/config"
tea "github.com/charmbracelet/bubbletea"
)
type moveFormModel struct {
fileSelector *fileSelectorModel
hostName string
configFile string
width int
height int
styles Styles
state moveFormState
}
type moveFormState int
const (
moveFormSelectingFile moveFormState = iota
moveFormProcessing
)
type moveFormSubmitMsg struct {
hostName string
targetFile string
err error
}
type moveFormCancelMsg struct{}
// NewMoveForm creates a new move form for moving a host to another config file
func NewMoveForm(hostName string, styles Styles, width, height int, configFile string) (*moveFormModel, error) {
// Get all config files except the one containing the current host
files, err := config.GetConfigFilesExcludingCurrent(hostName, configFile)
if err != nil {
return nil, fmt.Errorf("failed to get config files: %v", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("no other config files available to move host to")
}
// Create a custom file selector for move operation
fileSelector, err := newFileSelectorFromFiles(
fmt.Sprintf("Select destination config file for host '%s':", hostName),
styles,
width,
height,
files,
)
if err != nil {
return nil, fmt.Errorf("failed to create file selector: %v", err)
}
return &moveFormModel{
fileSelector: fileSelector,
hostName: hostName,
configFile: configFile,
width: width,
height: height,
styles: styles,
state: moveFormSelectingFile,
}, nil
}
func (m *moveFormModel) Init() tea.Cmd {
return m.fileSelector.Init()
}
func (m *moveFormModel) Update(msg tea.Msg) (*moveFormModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.styles = NewStyles(m.width)
if m.fileSelector != nil {
m.fileSelector.width = m.width
m.fileSelector.height = m.height
m.fileSelector.styles = m.styles
}
return m, nil
case tea.KeyMsg:
switch m.state {
case moveFormSelectingFile:
switch msg.String() {
case "enter":
if m.fileSelector != nil && len(m.fileSelector.files) > 0 {
selectedFile := m.fileSelector.files[m.fileSelector.selected]
m.state = moveFormProcessing
return m, m.submitMove(selectedFile)
}
case "esc", "q":
return m, func() tea.Msg { return moveFormCancelMsg{} }
default:
// Forward other keys to file selector
if m.fileSelector != nil {
newFileSelector, cmd := m.fileSelector.Update(msg)
m.fileSelector = newFileSelector
return m, cmd
}
}
case moveFormProcessing:
// Dans cet état, on attend le résultat de l'opération
// Le résultat sera géré par le modèle principal
switch msg.String() {
case "esc", "q":
return m, func() tea.Msg { return moveFormCancelMsg{} }
}
}
}
return m, nil
}
func (m *moveFormModel) View() string {
switch m.state {
case moveFormSelectingFile:
if m.fileSelector != nil {
return m.fileSelector.View()
}
return "Loading..."
case moveFormProcessing:
return m.styles.FormTitle.Render("Moving host...") + "\n\n" +
m.styles.HelpText.Render(fmt.Sprintf("Moving host '%s' to selected config file...", m.hostName))
default:
return "Unknown state"
}
}
func (m *moveFormModel) submitMove(targetFile string) tea.Cmd {
return func() tea.Msg {
err := config.MoveHostToFile(m.hostName, targetFile)
return moveFormSubmitMsg{
hostName: m.hostName,
targetFile: targetFile,
err: err,
}
}
}
// Standalone move form for CLI usage
type standaloneMoveForm struct {
moveFormModel *moveFormModel
}
func (m standaloneMoveForm) Init() tea.Cmd {
return m.moveFormModel.Init()
}
func (m standaloneMoveForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case moveFormCancelMsg:
return m, tea.Quit
case moveFormSubmitMsg:
// En mode standalone, on quitte après le déplacement (succès ou erreur)
return m, tea.Quit
}
newForm, cmd := m.moveFormModel.Update(msg)
m.moveFormModel = newForm
return m, cmd
}
func (m standaloneMoveForm) View() string {
return m.moveFormModel.View()
}
// RunMoveForm provides backward compatibility for standalone move form
func RunMoveForm(hostName string, configFile string) error {
styles := NewStyles(80)
moveForm, err := NewMoveForm(hostName, styles, 80, 24, configFile)
if err != nil {
return err
}
m := standaloneMoveForm{moveForm}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err = p.Run()
return err
}

View File

@@ -0,0 +1,555 @@
package ui
import (
"fmt"
"strconv"
"strings"
"github.com/Gu1llaum-3/sshm/internal/history"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Input field indices for port forward form
const (
pfTypeInput = iota
pfLocalPortInput
pfRemoteHostInput
pfRemotePortInput
pfBindAddressInput
)
type portForwardModel struct {
inputs []textinput.Model
focused int
forwardType PortForwardType
hostName string
err string
styles Styles
width int
height int
configFile string
historyManager *history.HistoryManager
}
// portForwardSubmitMsg is sent when the port forward form is submitted
type portForwardSubmitMsg struct {
err error
sshArgs []string
}
// portForwardCancelMsg is sent when the port forward form is cancelled
type portForwardCancelMsg struct{}
// NewPortForwardForm creates a new port forward form model
func NewPortForwardForm(hostName string, styles Styles, width, height int, configFile string, historyManager *history.HistoryManager) *portForwardModel {
inputs := make([]textinput.Model, 5)
// Forward type input (display only, controlled by arrow keys)
inputs[pfTypeInput] = textinput.New()
inputs[pfTypeInput].Placeholder = "Use ←/→ to change forward type"
inputs[pfTypeInput].Focus()
inputs[pfTypeInput].Width = 40
// Local port input
inputs[pfLocalPortInput] = textinput.New()
inputs[pfLocalPortInput].Placeholder = "8080"
inputs[pfLocalPortInput].CharLimit = 5
inputs[pfLocalPortInput].Width = 20
// Remote host input
inputs[pfRemoteHostInput] = textinput.New()
inputs[pfRemoteHostInput].Placeholder = "localhost"
inputs[pfRemoteHostInput].CharLimit = 100
inputs[pfRemoteHostInput].Width = 30
inputs[pfRemoteHostInput].SetValue("localhost")
// Remote port input
inputs[pfRemotePortInput] = textinput.New()
inputs[pfRemotePortInput].Placeholder = "80"
inputs[pfRemotePortInput].CharLimit = 5
inputs[pfRemotePortInput].Width = 20
// Bind address input (optional)
inputs[pfBindAddressInput] = textinput.New()
inputs[pfBindAddressInput].Placeholder = "127.0.0.1 (optional)"
inputs[pfBindAddressInput].CharLimit = 50
inputs[pfBindAddressInput].Width = 30
pf := &portForwardModel{
inputs: inputs,
focused: 0,
forwardType: LocalForward,
hostName: hostName,
styles: styles,
width: width,
height: height,
configFile: configFile,
historyManager: historyManager,
}
// Load previous port forwarding configuration if available
pf.loadPreviousConfig()
// Initialize input visibility
pf.updateInputVisibility()
return pf
}
func (m *portForwardModel) Init() tea.Cmd {
return textinput.Blink
}
func (m *portForwardModel) Update(msg tea.Msg) (*portForwardModel, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "ctrl+c":
return m, func() tea.Msg { return portForwardCancelMsg{} }
case "enter":
nextField := m.getNextValidField(m.focused)
if nextField != -1 {
// Move to next valid input
m.inputs[m.focused].Blur()
m.focused = nextField
m.inputs[m.focused].Focus()
return m, textinput.Blink
} else {
// Submit form
return m, m.submitForm()
}
case "shift+tab", "up":
prevField := m.getPrevValidField(m.focused)
if prevField != -1 {
m.inputs[m.focused].Blur()
m.focused = prevField
m.inputs[m.focused].Focus()
return m, textinput.Blink
}
case "tab", "down":
nextField := m.getNextValidField(m.focused)
if nextField != -1 {
m.inputs[m.focused].Blur()
m.focused = nextField
m.inputs[m.focused].Focus()
return m, textinput.Blink
}
case "left", "right":
if m.focused == pfTypeInput {
// Change forward type
if msg.String() == "left" {
if m.forwardType > 0 {
m.forwardType--
} else {
m.forwardType = DynamicForward
}
} else {
if m.forwardType < DynamicForward {
m.forwardType++
} else {
m.forwardType = LocalForward
}
}
m.inputs[pfTypeInput].SetValue(m.forwardType.String())
m.updateInputVisibility()
// Ensure focused field is valid for the new type
validFields := m.getValidFields()
validFocus := false
for _, field := range validFields {
if field == m.focused {
validFocus = true
break
}
}
if !validFocus && len(validFields) > 0 {
m.inputs[m.focused].Blur()
m.focused = validFields[0]
m.inputs[m.focused].Focus()
}
return m, nil
}
}
}
// Update the focused input
m.inputs[m.focused], cmd = m.inputs[m.focused].Update(msg)
return m, cmd
}
func (m *portForwardModel) updateInputVisibility() {
// Reset all inputs visibility
for i := range m.inputs {
if i != pfTypeInput {
m.inputs[i].Placeholder = ""
}
}
switch m.forwardType {
case LocalForward:
m.inputs[pfLocalPortInput].Placeholder = "Local port (e.g., 8080)"
m.inputs[pfRemoteHostInput].Placeholder = "Remote host (e.g., localhost)"
m.inputs[pfRemotePortInput].Placeholder = "Remote port (e.g., 80)"
m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional, default: 127.0.0.1)"
case RemoteForward:
m.inputs[pfLocalPortInput].Placeholder = "Remote port (e.g., 8080)"
m.inputs[pfRemoteHostInput].Placeholder = "Local host (e.g., localhost)"
m.inputs[pfRemotePortInput].Placeholder = "Local port (e.g., 80)"
m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional)"
case DynamicForward:
m.inputs[pfLocalPortInput].Placeholder = "SOCKS port (e.g., 1080)"
m.inputs[pfRemoteHostInput].Placeholder = ""
m.inputs[pfRemotePortInput].Placeholder = ""
m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional, default: 127.0.0.1)"
}
}
func (m *portForwardModel) View() string {
var sections []string
// Title
title := m.styles.Header.Render("🔗 Port Forwarding Setup")
sections = append(sections, title)
// Host info
hostInfo := fmt.Sprintf("Host: %s", m.hostName)
sections = append(sections, m.styles.HelpText.Render(hostInfo))
// Error message
if m.err != "" {
sections = append(sections, m.styles.Error.Render("Error: "+m.err))
}
// Form fields
var fields []string
// Forward type
typeLabel := "Forward Type:"
if m.focused == pfTypeInput {
typeLabel = m.styles.FocusedLabel.Render(typeLabel)
} else {
typeLabel = m.styles.Label.Render(typeLabel)
}
fields = append(fields, typeLabel)
fields = append(fields, m.inputs[pfTypeInput].View())
fields = append(fields, m.styles.HelpText.Render("Use ←/→ to change type"))
switch m.forwardType {
case LocalForward:
fields = append(fields, "")
fields = append(fields, m.styles.HelpText.Render("Local forwarding: ssh -L [bind_address:]local_port:remote_host:remote_port"))
fields = append(fields, "")
// Local port
localPortLabel := "Local Port:"
if m.focused == pfLocalPortInput {
localPortLabel = m.styles.FocusedLabel.Render(localPortLabel)
} else {
localPortLabel = m.styles.Label.Render(localPortLabel)
}
fields = append(fields, localPortLabel)
fields = append(fields, m.inputs[pfLocalPortInput].View())
// Remote host
remoteHostLabel := "Remote Host:"
if m.focused == pfRemoteHostInput {
remoteHostLabel = m.styles.FocusedLabel.Render(remoteHostLabel)
} else {
remoteHostLabel = m.styles.Label.Render(remoteHostLabel)
}
fields = append(fields, remoteHostLabel)
fields = append(fields, m.inputs[pfRemoteHostInput].View())
// Remote port
remotePortLabel := "Remote Port:"
if m.focused == pfRemotePortInput {
remotePortLabel = m.styles.FocusedLabel.Render(remotePortLabel)
} else {
remotePortLabel = m.styles.Label.Render(remotePortLabel)
}
fields = append(fields, remotePortLabel)
fields = append(fields, m.inputs[pfRemotePortInput].View())
case RemoteForward:
fields = append(fields, "")
fields = append(fields, m.styles.HelpText.Render("Remote forwarding: ssh -R [bind_address:]remote_port:local_host:local_port"))
fields = append(fields, "")
// Remote port
remotePortLabel := "Remote Port:"
if m.focused == pfLocalPortInput {
remotePortLabel = m.styles.FocusedLabel.Render(remotePortLabel)
} else {
remotePortLabel = m.styles.Label.Render(remotePortLabel)
}
fields = append(fields, remotePortLabel)
fields = append(fields, m.inputs[pfLocalPortInput].View())
// Local host
localHostLabel := "Local Host:"
if m.focused == pfRemoteHostInput {
localHostLabel = m.styles.FocusedLabel.Render(localHostLabel)
} else {
localHostLabel = m.styles.Label.Render(localHostLabel)
}
fields = append(fields, localHostLabel)
fields = append(fields, m.inputs[pfRemoteHostInput].View())
// Local port
localPortLabel := "Local Port:"
if m.focused == pfRemotePortInput {
localPortLabel = m.styles.FocusedLabel.Render(localPortLabel)
} else {
localPortLabel = m.styles.Label.Render(localPortLabel)
}
fields = append(fields, localPortLabel)
fields = append(fields, m.inputs[pfRemotePortInput].View())
case DynamicForward:
fields = append(fields, "")
fields = append(fields, m.styles.HelpText.Render("Dynamic forwarding (SOCKS proxy): ssh -D [bind_address:]port"))
fields = append(fields, "")
// SOCKS port
socksPortLabel := "SOCKS Port:"
if m.focused == pfLocalPortInput {
socksPortLabel = m.styles.FocusedLabel.Render(socksPortLabel)
} else {
socksPortLabel = m.styles.Label.Render(socksPortLabel)
}
fields = append(fields, socksPortLabel)
fields = append(fields, m.inputs[pfLocalPortInput].View())
}
// Bind address (for all types)
fields = append(fields, "")
bindLabel := "Bind Address (optional):"
if m.focused == pfBindAddressInput {
bindLabel = m.styles.FocusedLabel.Render(bindLabel)
} else {
bindLabel = m.styles.Label.Render(bindLabel)
}
fields = append(fields, bindLabel)
fields = append(fields, m.inputs[pfBindAddressInput].View())
// Join form fields
formContent := lipgloss.JoinVertical(lipgloss.Left, fields...)
sections = append(sections, formContent)
// Help text
helpText := " Tab/↓: next field • Shift+Tab/↑: previous field • Enter: connect • Esc: cancel"
sections = append(sections, m.styles.HelpText.Render(helpText))
// Join all sections
content := lipgloss.JoinVertical(lipgloss.Left, sections...)
// Center the form
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
m.styles.FormContainer.Render(content),
)
}
func (m *portForwardModel) submitForm() tea.Cmd {
return func() tea.Msg {
// Validate inputs
localPort := strings.TrimSpace(m.inputs[pfLocalPortInput].Value())
if localPort == "" {
return portForwardSubmitMsg{err: fmt.Errorf("port is required"), sshArgs: nil}
}
// Validate port number
if _, err := strconv.Atoi(localPort); err != nil {
return portForwardSubmitMsg{err: fmt.Errorf("invalid port number"), sshArgs: nil}
}
// Get form values for saving to history
remoteHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value())
remotePort := strings.TrimSpace(m.inputs[pfRemotePortInput].Value())
bindAddress := strings.TrimSpace(m.inputs[pfBindAddressInput].Value())
// Build SSH command with port forwarding
var sshArgs []string
// Add config file if specified
if m.configFile != "" {
sshArgs = append(sshArgs, "-F", m.configFile)
}
// Add forwarding arguments
var forwardTypeStr string
switch m.forwardType {
case LocalForward:
forwardTypeStr = "local"
if remoteHost == "" {
remoteHost = "localhost"
}
if remotePort == "" {
return portForwardSubmitMsg{err: fmt.Errorf("remote port is required for local forwarding"), sshArgs: nil}
}
// Validate remote port
if _, err := strconv.Atoi(remotePort); err != nil {
return portForwardSubmitMsg{err: fmt.Errorf("invalid remote port number"), sshArgs: nil}
}
// Build -L argument
var forwardArg string
if bindAddress != "" {
forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, remoteHost, remotePort)
} else {
forwardArg = fmt.Sprintf("%s:%s:%s", localPort, remoteHost, remotePort)
}
sshArgs = append(sshArgs, "-L", forwardArg)
case RemoteForward:
forwardTypeStr = "remote"
if remoteHost == "" {
remoteHost = "localhost"
}
if remotePort == "" {
return portForwardSubmitMsg{err: fmt.Errorf("local port is required for remote forwarding"), sshArgs: nil}
}
// Validate local port
if _, err := strconv.Atoi(remotePort); err != nil {
return portForwardSubmitMsg{err: fmt.Errorf("invalid local port number"), sshArgs: nil}
}
// Build -R argument (note: localPort is actually the remote port in this context)
var forwardArg string
if bindAddress != "" {
forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, remoteHost, remotePort)
} else {
forwardArg = fmt.Sprintf("%s:%s:%s", localPort, remoteHost, remotePort)
}
sshArgs = append(sshArgs, "-R", forwardArg)
case DynamicForward:
forwardTypeStr = "dynamic"
// Build -D argument
var forwardArg string
if bindAddress != "" {
forwardArg = fmt.Sprintf("%s:%s", bindAddress, localPort)
} else {
forwardArg = localPort
}
sshArgs = append(sshArgs, "-D", forwardArg)
}
// Save port forwarding configuration to history
if m.historyManager != nil {
if err := m.historyManager.RecordPortForwarding(
m.hostName,
forwardTypeStr,
localPort,
remoteHost,
remotePort,
bindAddress,
); err != nil {
// Log the error but don't fail the connection
// In a production environment, you might want to handle this differently
}
}
// Add hostname
sshArgs = append(sshArgs, m.hostName)
// Return success with the SSH command to execute
return portForwardSubmitMsg{err: nil, sshArgs: sshArgs}
}
}
// getValidFields returns the list of valid field indices for the current forward type
func (m *portForwardModel) getValidFields() []int {
switch m.forwardType {
case LocalForward:
return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput}
case RemoteForward:
return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput}
case DynamicForward:
return []int{pfTypeInput, pfLocalPortInput, pfBindAddressInput}
default:
return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput}
}
}
// getNextValidField returns the next valid field index, or -1 if none
func (m *portForwardModel) getNextValidField(currentField int) int {
validFields := m.getValidFields()
for i, field := range validFields {
if field == currentField && i < len(validFields)-1 {
return validFields[i+1]
}
}
return -1
}
// getPrevValidField returns the previous valid field index, or -1 if none
func (m *portForwardModel) getPrevValidField(currentField int) int {
validFields := m.getValidFields()
for i, field := range validFields {
if field == currentField && i > 0 {
return validFields[i-1]
}
}
return -1
}
// loadPreviousConfig loads the previous port forwarding configuration for this host
func (m *portForwardModel) loadPreviousConfig() {
if m.historyManager == nil {
m.inputs[pfTypeInput].SetValue("Local (-L)")
return
}
config := m.historyManager.GetPortForwardingConfig(m.hostName)
if config == nil {
m.inputs[pfTypeInput].SetValue("Local (-L)")
return
}
// Set forward type based on saved configuration
switch config.Type {
case "local":
m.forwardType = LocalForward
case "remote":
m.forwardType = RemoteForward
case "dynamic":
m.forwardType = DynamicForward
default:
m.forwardType = LocalForward
}
m.inputs[pfTypeInput].SetValue(m.forwardType.String())
// Set values from saved configuration
if config.LocalPort != "" {
m.inputs[pfLocalPortInput].SetValue(config.LocalPort)
}
if config.RemoteHost != "" {
m.inputs[pfRemoteHostInput].SetValue(config.RemoteHost)
} else if m.forwardType != DynamicForward {
// Default to localhost for local and remote forwarding if not set
m.inputs[pfRemoteHostInput].SetValue("localhost")
}
if config.RemotePort != "" {
m.inputs[pfRemotePortInput].SetValue(config.RemotePort)
}
if config.BindAddress != "" {
m.inputs[pfBindAddressInput].SetValue(config.BindAddress)
}
}

305
internal/ui/search_test.go Normal file
View File

@@ -0,0 +1,305 @@
package ui
import (
"testing"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
// createTestModel creates a model with test data for testing
func createTestModel() Model {
hosts := []config.SSHHost{
{Name: "server1", Hostname: "server1.example.com", User: "user1"},
{Name: "server2", Hostname: "server2.example.com", User: "user2"},
{Name: "server3", Hostname: "server3.example.com", User: "user3"},
{Name: "web-server", Hostname: "web.example.com", User: "webuser"},
{Name: "db-server", Hostname: "db.example.com", User: "dbuser"},
}
m := Model{
hosts: hosts,
filteredHosts: hosts,
searchInput: textinput.New(),
table: table.New(),
searchMode: false,
ready: true,
width: 80,
height: 24,
styles: NewStyles(80),
}
// Initialize table with test data
m.updateTableColumns()
m.updateTableRows()
return m
}
func TestSearchModeToggle(t *testing.T) {
m := createTestModel()
// Initially should not be in search mode
if m.searchMode {
t.Error("Model should not start in search mode")
}
// Simulate pressing "/" to enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Should now be in search mode
if !m.searchMode {
t.Error("Model should be in search mode after pressing '/'")
}
// The search input should be focused
if !m.searchInput.Focused() {
t.Error("Search input should be focused in search mode")
}
}
func TestSearchFiltering(t *testing.T) {
m := createTestModel()
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Type "server" in search
for _, char := range "server" {
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// Should filter to only hosts containing "server"
expectedHosts := []string{"server1", "server2", "server3", "web-server", "db-server"}
if len(m.filteredHosts) != len(expectedHosts) {
t.Errorf("Expected %d filtered hosts, got %d", len(expectedHosts), len(m.filteredHosts))
}
// Check that all filtered hosts contain "server"
for _, host := range m.filteredHosts {
found := false
for _, expected := range expectedHosts {
if host.Name == expected {
found = true
break
}
}
if !found {
t.Errorf("Unexpected host in filtered results: %s", host.Name)
}
}
}
func TestSearchFilteringSpecific(t *testing.T) {
m := createTestModel()
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Type "web" in search
for _, char := range "web" {
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// Should filter to only hosts containing "web"
if len(m.filteredHosts) != 1 {
t.Errorf("Expected 1 filtered host, got %d", len(m.filteredHosts))
}
if len(m.filteredHosts) > 0 && m.filteredHosts[0].Name != "web-server" {
t.Errorf("Expected 'web-server', got '%s'", m.filteredHosts[0].Name)
}
}
func TestSearchClearReturnToOriginal(t *testing.T) {
m := createTestModel()
originalHostCount := len(m.hosts)
// Enter search mode and type something
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Type "web" in search
for _, char := range "web" {
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// Should have filtered results
if len(m.filteredHosts) >= originalHostCount {
t.Error("Search should have filtered down the results")
}
// Clear the search by simulating backspace
for i := 0; i < 3; i++ { // "web" is 3 characters
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// Should return to all hosts
if len(m.filteredHosts) != originalHostCount {
t.Errorf("Expected %d hosts after clearing search, got %d", originalHostCount, len(m.filteredHosts))
}
}
func TestCursorPositionAfterFiltering(t *testing.T) {
m := createTestModel()
// Move cursor down to position 2 (third item)
m.table.SetCursor(2)
initialCursor := m.table.Cursor()
if initialCursor != 2 {
t.Errorf("Expected cursor at position 2, got %d", initialCursor)
}
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Type "web" - this will filter to only 1 result
for _, char := range "web" {
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// Cursor should be reset to 0 since filtered results has only 1 item
// and cursor position 2 would be out of bounds
if len(m.filteredHosts) == 1 && m.table.Cursor() != 0 {
t.Errorf("Expected cursor to be reset to 0 when filtered results are smaller, got %d", m.table.Cursor())
}
}
func TestTabSwitchBetweenSearchAndTable(t *testing.T) {
m := createTestModel()
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
if !m.searchMode {
t.Error("Should be in search mode")
}
// Press Tab to switch to table
keyMsg = tea.KeyMsg{Type: tea.KeyTab}
newModel, _ = m.Update(keyMsg)
m = newModel.(Model)
if m.searchMode {
t.Error("Should not be in search mode after Tab")
}
// Press Tab again to switch back to search
keyMsg = tea.KeyMsg{Type: tea.KeyTab}
newModel, _ = m.Update(keyMsg)
m = newModel.(Model)
if !m.searchMode {
t.Error("Should be in search mode after second Tab")
}
}
func TestEnterExitsSearchMode(t *testing.T) {
m := createTestModel()
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
if !m.searchMode {
t.Error("Should be in search mode")
}
// Press Enter to exit search mode
keyMsg = tea.KeyMsg{Type: tea.KeyEnter}
newModel, _ = m.Update(keyMsg)
m = newModel.(Model)
if m.searchMode {
t.Error("Should not be in search mode after Enter")
}
}
func TestSearchModeDoesNotTriggerOnEmptyInput(t *testing.T) {
m := createTestModel()
originalHostCount := len(m.hosts)
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// At this point, filteredHosts should still be the same as the original hosts
// because entering search mode should not trigger filtering with empty input
if len(m.filteredHosts) != originalHostCount {
t.Errorf("Expected %d hosts when entering search mode, got %d", originalHostCount, len(m.filteredHosts))
}
}
func TestSearchByHostname(t *testing.T) {
m := createTestModel()
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Search by hostname part "example.com"
searchTerm := "example.com"
for _, char := range searchTerm {
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// All hosts should match since they all have "example.com" in hostname
if len(m.filteredHosts) != len(m.hosts) {
t.Errorf("Expected all %d hosts to match hostname search, got %d", len(m.hosts), len(m.filteredHosts))
}
}
func TestSearchByUser(t *testing.T) {
m := createTestModel()
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Search by user "user1"
searchTerm := "user1"
for _, char := range searchTerm {
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// Only server1 should match
if len(m.filteredHosts) != 1 {
t.Errorf("Expected 1 host to match user search, got %d", len(m.filteredHosts))
}
if len(m.filteredHosts) > 0 && m.filteredHosts[0].Name != "server1" {
t.Errorf("Expected 'server1' to match user search, got '%s'", m.filteredHosts[0].Name)
}
}

View File

@@ -4,7 +4,7 @@ import (
"sort"
"strings"
"sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/config"
)
// sortHosts sorts hosts according to the current sort mode
@@ -57,6 +57,12 @@ func (m Model) filterHosts(query string) []config.SSHHost {
continue
}
// Check the user
if strings.Contains(strings.ToLower(host.User), query) {
filtered = append(filtered, host)
continue
}
// Check the tags
for _, tag := range host.Tags {
if strings.Contains(strings.ToLower(tag), query) {

View File

@@ -39,6 +39,10 @@ type Styles struct {
FormTitle lipgloss.Style
FormField lipgloss.Style
FormHelp lipgloss.Style
FormContainer lipgloss.Style
Label lipgloss.Style
FocusedLabel lipgloss.Style
HelpSection lipgloss.Style
}
// NewStyles creates a new Styles struct with the given terminal width
@@ -85,8 +89,7 @@ func NewStyles(width int) Styles {
Foreground(lipgloss.Color(SecondaryColor)),
HelpText: lipgloss.NewStyle().
Foreground(lipgloss.Color(SecondaryColor)).
MarginTop(1),
Foreground(lipgloss.Color(SecondaryColor)),
// Error style
Error: lipgloss.NewStyle().
@@ -105,6 +108,20 @@ func NewStyles(width int) Styles {
FormHelp: lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262")),
FormContainer: lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(PrimaryColor)).
Padding(1, 2),
Label: lipgloss.NewStyle().
Foreground(lipgloss.Color(SecondaryColor)),
FocusedLabel: lipgloss.NewStyle().
Foreground(lipgloss.Color(PrimaryColor)),
HelpSection: lipgloss.NewStyle().
Padding(0, 2),
}
}

View File

@@ -3,12 +3,267 @@ package ui
import (
"strings"
"sshm/internal/config"
"sshm/internal/history"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/history"
"github.com/charmbracelet/bubbles/table"
)
// calculateDynamicColumnWidths calculates optimal column widths based on terminal width
// and content length, ensuring all content fits when possible
func (m *Model) calculateDynamicColumnWidths(hosts []config.SSHHost) (int, int, int, int) {
if m.width <= 0 {
// Fallback to static widths if terminal width is not available
return calculateNameColumnWidth(hosts), 25, calculateTagsColumnWidth(hosts), calculateLastLoginColumnWidth(hosts, m.historyManager)
}
// Calculate content lengths
maxNameLength := 8 // Minimum for "Name" header + status indicator
maxHostnameLength := 8 // Minimum for "Hostname" header
maxTagsLength := 8 // Minimum for "Tags" header
maxLastLoginLength := 12 // Minimum for "Last Login" header
for _, host := range hosts {
// Name column includes status indicator (2 chars) + space (1 char) + name
nameLength := 3 + len(host.Name)
if nameLength > maxNameLength {
maxNameLength = nameLength
}
if len(host.Hostname) > maxHostnameLength {
maxHostnameLength = len(host.Hostname)
}
// Calculate tags string length
var tagsStr string
if len(host.Tags) > 0 {
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
if len(tagsStr) > maxTagsLength {
maxTagsLength = len(tagsStr)
}
// Calculate last login length
if m.historyManager != nil {
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
timeStr := formatTimeAgo(lastConnect)
if len(timeStr) > maxLastLoginLength {
maxLastLoginLength = len(timeStr)
}
}
}
}
// Add padding to each column
maxNameLength += 2
maxHostnameLength += 2
maxTagsLength += 2
maxLastLoginLength += 2
// Calculate available width (minus borders and separators)
// Table has borders (2 chars) + column separators (3 chars between 4 columns)
availableWidth := m.width - 5
totalNeededWidth := maxNameLength + maxHostnameLength + maxTagsLength + maxLastLoginLength
if totalNeededWidth <= availableWidth {
// Everything fits perfectly
return maxNameLength, maxHostnameLength, maxTagsLength, maxLastLoginLength
}
// Need to adjust widths - prioritize columns by importance
// Priority: Name > Hostname > Last Login > Tags
// Calculate minimum widths
minNameWidth := 15 // Enough for status + short name
minHostnameWidth := 15
minLastLoginWidth := 12
minTagsWidth := 10
remainingWidth := availableWidth
// Allocate minimum widths first
nameWidth := minNameWidth
hostnameWidth := minHostnameWidth
lastLoginWidth := minLastLoginWidth
tagsWidth := minTagsWidth
remainingWidth -= (nameWidth + hostnameWidth + lastLoginWidth + tagsWidth)
// Distribute remaining space proportionally
if remainingWidth > 0 {
// Calculate how much each column wants beyond minimum
nameWant := maxNameLength - minNameWidth
hostnameWant := maxHostnameLength - minHostnameWidth
lastLoginWant := maxLastLoginLength - minLastLoginWidth
tagsWant := maxTagsLength - minTagsWidth
totalWant := nameWant + hostnameWant + lastLoginWant + tagsWant
if totalWant > 0 {
// Distribute proportionally
nameExtra := (nameWant * remainingWidth) / totalWant
hostnameExtra := (hostnameWant * remainingWidth) / totalWant
lastLoginExtra := (lastLoginWant * remainingWidth) / totalWant
tagsExtra := remainingWidth - nameExtra - hostnameExtra - lastLoginExtra
nameWidth += nameExtra
hostnameWidth += hostnameExtra
lastLoginWidth += lastLoginExtra
tagsWidth += tagsExtra
}
}
return nameWidth, hostnameWidth, tagsWidth, lastLoginWidth
}
// updateTableRows updates the table with filtered hosts
func (m *Model) updateTableRows() {
var rows []table.Row
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
for _, host := range hostsToShow {
// Get ping status indicator
statusIndicator := m.getPingStatusIndicator(host.Name)
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
// Add the # prefix to each tag and join them with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
// Format last login information
var lastLoginStr string
if m.historyManager != nil {
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
lastLoginStr = formatTimeAgo(lastConnect)
}
}
rows = append(rows, table.Row{
statusIndicator + " " + host.Name,
host.Hostname,
// host.User, // Commented to save space
// host.Port, // Commented to save space
tagsStr,
lastLoginStr,
})
}
m.table.SetRows(rows)
// Update table height and columns based on current terminal size
m.updateTableHeight()
m.updateTableColumns()
}
// updateTableHeight dynamically adjusts table height based on terminal size
func (m *Model) updateTableHeight() {
if !m.ready {
return
}
// Calculate dynamic table height based on terminal size
// Layout breakdown:
// - ASCII title: 5 lines (1 empty + 4 text lines)
// - Update banner : 1 line (if present)
// - Search bar: 1 line
// - Help text: 1 line
// - App margins/spacing: 3 lines
// - Safety margin: 3 lines (to ensure UI elements are always visible)
// Total reserved: 14 lines minimum to preserve essential UI elements
reservedHeight := 14
availableHeight := m.height - reservedHeight
hostCount := len(m.table.Rows())
// Minimum height should be at least 3 rows for basic usability
// Even in very small terminals, we want to show at least header + 2 hosts
minTableHeight := 4 // 1 header + 3 data rows minimum
maxTableHeight := availableHeight
if maxTableHeight < minTableHeight {
maxTableHeight = minTableHeight
}
tableHeight := 1 // header
dataRowsNeeded := hostCount
maxDataRows := maxTableHeight - 1 // subtract 1 for header
if dataRowsNeeded <= maxDataRows {
// We have enough space for all hosts
tableHeight += dataRowsNeeded
} else {
// We need to limit to available space
tableHeight += maxDataRows
}
// Add one extra line to prevent the last host from being hidden
// This compensates for table rendering quirks in bubble tea
tableHeight += 1
// Update table height
m.table.SetHeight(tableHeight)
}
// updateTableColumns dynamically adjusts table column widths based on terminal size
func (m *Model) updateTableColumns() {
if !m.ready {
return
}
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
// Use dynamic column width calculation
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(hostsToShow)
// Create new columns with updated widths and sort indicators
nameTitle := "Name"
lastLoginTitle := "Last Login"
// Add sort indicators based on current sort mode
switch m.sortMode {
case SortByName:
nameTitle += " ↓"
case SortByLastUsed:
lastLoginTitle += " ↓"
}
columns := []table.Column{
{Title: nameTitle, Width: nameWidth},
{Title: "Hostname", Width: hostnameWidth},
// {Title: "User", Width: userWidth}, // Commented to save space
// {Title: "Port", Width: portWidth}, // Commented to save space
{Title: "Tags", Width: tagsWidth},
{Title: lastLoginTitle, Width: lastLoginWidth},
}
m.table.SetColumns(columns)
}
// max returns the maximum of two integers
func max(a, b int) int {
if a > b {
return a
}
return b
}
// Legacy functions for compatibility
// calculateNameColumnWidth calculates the optimal width for the Name column
// based on the longest hostname, with a minimum of 8 and maximum of 40 characters
func calculateNameColumnWidth(hosts []config.SSHHost) int {
@@ -90,159 +345,3 @@ func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *histo
return maxLength
}
// updateTableRows updates the table with filtered hosts
func (m *Model) updateTableRows() {
var rows []table.Row
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
for _, host := range hostsToShow {
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
// Add the # prefix to each tag and join them with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
// Format last login information
var lastLoginStr string
if m.historyManager != nil {
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
lastLoginStr = formatTimeAgo(lastConnect)
}
}
rows = append(rows, table.Row{
host.Name,
host.Hostname,
host.User,
host.Port,
tagsStr,
lastLoginStr,
})
}
m.table.SetRows(rows)
// Update table height and columns based on current terminal size
m.updateTableHeight()
m.updateTableColumns()
}
// updateTableHeight dynamically adjusts table height based on terminal size
func (m *Model) updateTableHeight() {
if !m.ready {
return
}
// Calculate dynamic table height based on terminal size
// Layout breakdown:
// - ASCII title: 5 lines (1 empty + 4 text lines)
// - Search bar: 1 line
// - Sort info: 1 line
// - Help text: 2 lines (multi-line text)
// - App margins/spacing: 2 lines
// Total reserved: 11 lines, mais réduisons à 7 pour forcer plus d'espace
reservedHeight := 7 // Réduction agressive pour tester
availableHeight := m.height - reservedHeight
hostCount := len(m.table.Rows())
// Minimum height should be at least 5 rows for usability
minTableHeight := 6 // 1 header + 5 data rows
maxTableHeight := availableHeight
if maxTableHeight < minTableHeight {
maxTableHeight = minTableHeight
}
tableHeight := 1 // header
dataRowsNeeded := hostCount
maxDataRows := maxTableHeight - 1 // subtract 1 for header
if dataRowsNeeded <= maxDataRows {
// We have enough space for all hosts
tableHeight += dataRowsNeeded
} else {
// We need to limit to available space
tableHeight += maxDataRows
}
// FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème
tableHeight += 1
// Update table height
m.table.SetHeight(tableHeight)
}
// updateTableColumns dynamically adjusts table column widths based on terminal size
func (m *Model) updateTableColumns() {
if !m.ready {
return
}
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
// Calculate base column widths
nameWidth := calculateNameColumnWidth(hostsToShow)
tagsWidth := calculateTagsColumnWidth(hostsToShow)
lastLoginWidth := calculateLastLoginColumnWidth(hostsToShow, m.historyManager)
// Fixed column widths
hostnameWidth := 25
userWidth := 12
portWidth := 6
// Calculate total width needed for all columns
totalFixedWidth := hostnameWidth + userWidth + portWidth
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
totalWidth := totalFixedWidth + totalVariableWidth
// Available width (accounting for table borders and padding)
availableWidth := m.width - 4 // 4 chars for borders and padding
// If the table is too wide, scale down the variable columns proportionally
if totalWidth > availableWidth {
excessWidth := totalWidth - availableWidth
variableColumnsWidth := totalVariableWidth
if variableColumnsWidth > 0 {
// Reduce variable columns proportionally
nameReduction := (excessWidth * nameWidth) / variableColumnsWidth
tagsReduction := (excessWidth * tagsWidth) / variableColumnsWidth
lastLoginReduction := excessWidth - nameReduction - tagsReduction
nameWidth = max(8, nameWidth-nameReduction)
tagsWidth = max(8, tagsWidth-tagsReduction)
lastLoginWidth = max(10, lastLoginWidth-lastLoginReduction)
}
}
// Create new columns with updated widths
columns := []table.Column{
{Title: "Name", Width: nameWidth},
{Title: "Hostname", Width: hostnameWidth},
{Title: "User", Width: userWidth},
{Title: "Port", Width: portWidth},
{Title: "Tags", Width: tagsWidth},
{Title: "Last Login", Width: lastLoginWidth},
}
m.table.SetColumns(columns)
}
// max returns the maximum of two integers
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -3,9 +3,11 @@ package ui
import (
"fmt"
"strings"
"time"
"sshm/internal/config"
"sshm/internal/history"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/connectivity"
"github.com/Gu1llaum-3/sshm/internal/history"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
@@ -14,7 +16,7 @@ import (
)
// NewModel creates a new TUI model with the given SSH hosts
func NewModel(hosts []config.SSHHost, configFile string) Model {
func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
// Initialize the history manager
historyManager, err := history.NewHistoryManager()
if err != nil {
@@ -26,12 +28,17 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
// Create initial styles (will be updated on first WindowSizeMsg)
styles := NewStyles(80) // Default width
// Initialize ping manager with 5 second timeout
pingManager := connectivity.NewPingManager(5 * time.Second)
// Create the model with default sorting by name
m := Model{
hosts: hosts,
historyManager: historyManager,
pingManager: pingManager,
sortMode: SortByName,
configFile: configFile,
currentVersion: currentVersion,
styles: styles,
width: 80,
height: 24,
@@ -46,23 +53,17 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
ti := textinput.New()
ti.Placeholder = "Search hosts or tags..."
ti.CharLimit = 50
ti.Width = 50
ti.Width = 25
// Calculate optimal width for the Name column
nameWidth := calculateNameColumnWidth(sortedHosts)
// Calculate optimal width for the Tags column
tagsWidth := calculateTagsColumnWidth(sortedHosts)
// Calculate optimal width for the Last Login column
lastLoginWidth := calculateLastLoginColumnWidth(sortedHosts, historyManager)
// Use dynamic column width calculation (will fallback to static if width not available)
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts)
// Create table columns
columns := []table.Column{
{Title: "Name", Width: nameWidth},
{Title: "Hostname", Width: 25},
{Title: "User", Width: 12},
{Title: "Port", Width: 6},
{Title: "Hostname", Width: hostnameWidth},
// {Title: "User", Width: 12}, // Commented to save space
// {Title: "Port", Width: 6}, // Commented to save space
{Title: "Tags", Width: tagsWidth},
{Title: "Last Login", Width: lastLoginWidth},
}
@@ -70,6 +71,9 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
// Convert hosts to table rows
var rows []table.Row
for _, host := range sortedHosts {
// Get ping status indicator
statusIndicator := m.getPingStatusIndicator(host.Name)
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
@@ -90,10 +94,10 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
}
rows = append(rows, table.Row{
host.Name,
statusIndicator + " " + host.Name,
host.Hostname,
host.User,
host.Port,
// host.User, // Commented to save space
// host.Port, // Commented to save space
tagsStr,
lastLoginStr,
})
@@ -133,8 +137,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
}
// RunInteractiveMode starts the interactive TUI interface
func RunInteractiveMode(hosts []config.SSHHost, configFile string) error {
m := NewModel(hosts, configFile)
func RunInteractiveMode(hosts []config.SSHHost, configFile, currentVersion string) error {
m := NewModel(hosts, configFile, currentVersion)
// Start the application in alt screen mode for clean output
p := tea.NewProgram(m, tea.WithAltScreen())

View File

@@ -1,21 +1,84 @@
package ui
import (
"context"
"fmt"
"os/exec"
"time"
"sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/connectivity"
"github.com/Gu1llaum-3/sshm/internal/version"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
// Messages for SSH ping functionality and version checking
type (
pingResultMsg *connectivity.HostPingResult
versionCheckMsg *version.UpdateInfo
versionErrorMsg error
)
// startPingAllCmd creates a command to ping all hosts concurrently
func (m Model) startPingAllCmd() tea.Cmd {
if m.pingManager == nil {
return nil
}
return tea.Batch(
// Create individual ping commands for each host
func() tea.Cmd {
var cmds []tea.Cmd
for _, host := range m.hosts {
cmds = append(cmds, pingSingleHostCmd(m.pingManager, host))
}
return tea.Batch(cmds...)
}(),
)
}
// listenForPingResultsCmd is no longer needed since we use individual ping commands
// pingSingleHostCmd creates a command to ping a single host
func pingSingleHostCmd(pingManager *connectivity.PingManager, host config.SSHHost) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result := pingManager.PingHost(ctx, host)
return pingResultMsg(result)
}
}
// checkVersionCmd creates a command to check for version updates
func checkVersionCmd(currentVersion string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
updateInfo, err := version.CheckForUpdates(ctx, currentVersion)
if err != nil {
return versionErrorMsg(err)
}
return versionCheckMsg(updateInfo)
}
}
// Init initializes the model
func (m Model) Init() tea.Cmd {
return tea.Batch(
textinput.Blink,
// Ajoute ici d'autres tea.Cmd si tu veux charger des données, démarrer un spinner, etc.
)
var cmds []tea.Cmd
// Basic initialization commands
cmds = append(cmds, textinput.Blink)
// Check for version updates if we have a current version
if m.currentVersion != "" {
cmds = append(cmds, checkVersionCmd(m.currentVersion))
}
return tea.Batch(cmds...)
}
// Update handles model updates
@@ -46,6 +109,52 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editForm.height = m.height
m.editForm.styles = m.styles
}
if m.moveForm != nil {
m.moveForm.width = m.width
m.moveForm.height = m.height
m.moveForm.styles = m.styles
}
if m.infoForm != nil {
m.infoForm.width = m.width
m.infoForm.height = m.height
m.infoForm.styles = m.styles
}
if m.portForwardForm != nil {
m.portForwardForm.width = m.width
m.portForwardForm.height = m.height
m.portForwardForm.styles = m.styles
}
if m.helpForm != nil {
m.helpForm.width = m.width
m.helpForm.height = m.height
m.helpForm.styles = m.styles
}
if m.fileSelectorForm != nil {
m.fileSelectorForm.width = m.width
m.fileSelectorForm.height = m.height
m.fileSelectorForm.styles = m.styles
}
return m, nil
case pingResultMsg:
// Handle ping result - update table display
if msg != nil {
// Update the table to reflect the new ping status
m.updateTableRows()
}
return m, nil
case versionCheckMsg:
// Handle version check result
if msg != nil {
m.updateInfo = msg
}
return m, nil
case versionErrorMsg:
// Handle version check error (silently - not critical)
// We don't want to show error messages for version checks
// as it might disrupt the user experience
return m, nil
case addFormSubmitMsg:
@@ -136,6 +245,134 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.Focus()
return m, nil
case moveFormSubmitMsg:
if msg.err != nil {
// En cas d'erreur, on pourrait afficher une notification ou retourner à la liste
// Pour l'instant, on retourne simplement à la liste
m.viewMode = ViewList
m.moveForm = nil
m.table.Focus()
return m, nil
} else {
// Success: refresh hosts and return to list view
var hosts []config.SSHHost
var err error
if m.configFile != "" {
hosts, err = config.ParseSSHConfigFile(m.configFile)
} else {
hosts, err = config.ParseSSHConfig()
}
if err != nil {
return m, tea.Quit
}
m.hosts = m.sortHosts(hosts)
// Reapply search filter if there is one active
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
m.viewMode = ViewList
m.moveForm = nil
m.table.Focus()
return m, nil
}
case moveFormCancelMsg:
// Cancel: return to list view
m.viewMode = ViewList
m.moveForm = nil
m.table.Focus()
return m, nil
case infoFormCancelMsg:
// Cancel: return to list view
m.viewMode = ViewList
m.infoForm = nil
m.table.Focus()
return m, nil
case fileSelectorMsg:
if msg.cancelled {
// Cancel: return to list view
m.viewMode = ViewList
m.fileSelectorForm = nil
m.table.Focus()
return m, nil
} else {
// File selected: proceed to add form with selected file
m.addForm = NewAddForm("", m.styles, m.width, m.height, msg.selectedFile)
m.viewMode = ViewAdd
m.fileSelectorForm = nil
return m, textinput.Blink
}
case infoFormEditMsg:
// Switch from info to edit mode
editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile)
if err != nil {
// Handle error - could show in UI, for now just go back to list
m.viewMode = ViewList
m.infoForm = nil
m.table.Focus()
return m, nil
}
m.editForm = editForm
m.infoForm = nil
m.viewMode = ViewEdit
return m, textinput.Blink
case portForwardSubmitMsg:
if msg.err != nil {
// Show error in form
if m.portForwardForm != nil {
m.portForwardForm.err = msg.err.Error()
}
return m, nil
} else {
// Success: execute SSH command with port forwarding
if len(msg.sshArgs) > 0 {
sshCmd := exec.Command("ssh", msg.sshArgs...)
// Record the connection in history
if m.historyManager != nil && m.portForwardForm != nil {
err := m.historyManager.RecordConnection(m.portForwardForm.hostName)
if err != nil {
fmt.Printf("Warning: Could not record connection history: %v\n", err)
}
}
return m, tea.ExecProcess(sshCmd, func(err error) tea.Msg {
return tea.Quit()
})
}
// If no SSH args, just return to list view
m.viewMode = ViewList
m.portForwardForm = nil
m.table.Focus()
return m, nil
}
case portForwardCancelMsg:
// Cancel: return to list view
m.viewMode = ViewList
m.portForwardForm = nil
m.table.Focus()
return m, nil
case helpCloseMsg:
// Close help: return to list view
m.viewMode = ViewList
m.helpForm = nil
m.table.Focus()
return m, nil
case tea.KeyMsg:
// Handle view-specific key presses
switch m.viewMode {
@@ -153,6 +390,41 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editForm = newForm
return m, cmd
}
case ViewMove:
if m.moveForm != nil {
var newForm *moveFormModel
newForm, cmd = m.moveForm.Update(msg)
m.moveForm = newForm
return m, cmd
}
case ViewInfo:
if m.infoForm != nil {
var newForm *infoFormModel
newForm, cmd = m.infoForm.Update(msg)
m.infoForm = newForm
return m, cmd
}
case ViewPortForward:
if m.portForwardForm != nil {
var newForm *portForwardModel
newForm, cmd = m.portForwardForm.Update(msg)
m.portForwardForm = newForm
return m, cmd
}
case ViewHelp:
if m.helpForm != nil {
var newForm *helpModel
newForm, cmd = m.helpForm.Update(msg)
m.helpForm = newForm
return m, cmd
}
case ViewFileSelector:
if m.fileSelectorForm != nil {
var newForm *fileSelectorModel
newForm, cmd = m.fileSelectorForm.Update(msg)
m.fileSelectorForm = newForm
return m, cmd
}
case ViewList:
// Handle list view keys
return m.handleListViewKeys(msg)
@@ -186,6 +458,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.updateTableStyles()
m.table.Blur()
m.searchInput.Focus()
// Don't trigger filtering when entering search mode - wait for user input
return m, textinput.Blink
}
case "tab":
@@ -203,6 +476,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.updateTableStyles()
m.table.Blur()
m.searchInput.Focus()
// Don't trigger filtering when switching to search mode
return m, textinput.Blink
}
return m, nil
@@ -265,7 +539,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Connect to the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // The hostname is in the first column
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
// Record the connection in history
if m.historyManager != nil {
@@ -294,7 +568,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Edit the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // The hostname is in the first column
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile)
if err != nil {
// Handle error - could show in UI
@@ -305,11 +579,74 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, textinput.Blink
}
}
case "m":
if !m.searchMode && !m.deleteMode {
// Move the selected host to another config file
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
moveForm, err := NewMoveForm(hostName, m.styles, m.width, m.height, m.configFile)
if err != nil {
// Handle error - could show in UI, e.g., no other config files available
return m, nil
}
m.moveForm = moveForm
m.viewMode = ViewMove
return m, textinput.Blink
}
}
case "i":
if !m.searchMode && !m.deleteMode {
// Show info for the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile)
if err != nil {
// Handle error - could show in UI
return m, nil
}
m.infoForm = infoForm
m.viewMode = ViewInfo
return m, nil
}
}
case "a":
if !m.searchMode && !m.deleteMode {
// Add a new host
// Check if there are multiple config files starting from the current base config
var configFiles []string
var err error
if m.configFile != "" {
// Use the specified config file as base
configFiles, err = config.GetAllConfigFilesFromBase(m.configFile)
} else {
// Use the default config file as base
configFiles, err = config.GetAllConfigFiles()
}
if err != nil || len(configFiles) <= 1 {
// Only one config file (or error), go directly to add form
var configFile string
if len(configFiles) == 1 {
configFile = configFiles[0]
} else {
configFile = m.configFile
}
m.addForm = NewAddForm("", m.styles, m.width, m.height, configFile)
m.viewMode = ViewAdd
} else {
// Multiple config files, show file selector
fileSelectorForm, err := NewFileSelectorFromBase("Select config file to add host to:", m.styles, m.width, m.height, m.configFile)
if err != nil {
// Fallback to default behavior if file selector fails
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
m.viewMode = ViewAdd
} else {
m.fileSelectorForm = fileSelectorForm
m.viewMode = ViewFileSelector
}
}
return m, textinput.Blink
}
case "d":
@@ -317,13 +654,36 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Delete the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // The hostname is in the first column
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
m.deleteMode = true
m.deleteHost = hostName
m.table.Blur()
return m, nil
}
}
case "p":
if !m.searchMode && !m.deleteMode {
// Ping all hosts
return m, m.startPingAllCmd()
}
case "f":
if !m.searchMode && !m.deleteMode {
// Port forwarding for the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile, m.historyManager)
m.viewMode = ViewPortForward
return m, textinput.Blink
}
}
case "h":
if !m.searchMode && !m.deleteMode {
// Show help
m.helpForm = NewHelpForm(m.styles, m.width, m.height)
m.viewMode = ViewHelp
return m, nil
}
case "s":
if !m.searchMode && !m.deleteMode {
// Cycle through sort modes (only 2 modes now)
@@ -371,12 +731,17 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.searchInput, cmd = m.searchInput.Update(msg)
// Update filtered hosts only if the search value has changed
if m.searchInput.Value() != oldValue {
currentCursor := m.table.Cursor()
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.sortHosts(m.hosts)
}
m.updateTableRows()
// If the current cursor position is beyond the filtered results, reset to 0
if currentCursor >= len(m.filteredHosts) && len(m.filteredHosts) > 0 {
m.table.SetCursor(0)
}
}
} else {
m.table, cmd = m.table.Update(msg)

View File

@@ -2,6 +2,8 @@ package ui
import (
"fmt"
"github.com/Gu1llaum-3/sshm/internal/connectivity"
"strings"
"time"
)
@@ -55,3 +57,49 @@ func formatTimeAgo(t time.Time) string {
return fmt.Sprintf("%d years ago", years)
}
}
// formatConfigFile formats a config file path for display
func formatConfigFile(filePath string) string {
if filePath == "" {
return "Unknown"
}
// Show just the filename and parent directory for readability
parts := strings.Split(filePath, "/")
if len(parts) >= 2 {
return fmt.Sprintf(".../%s/%s", parts[len(parts)-2], parts[len(parts)-1])
}
return filePath
}
// getPingStatusIndicator returns a colored circle indicator based on ping status
func (m *Model) getPingStatusIndicator(hostName string) string {
if m.pingManager == nil {
return "⚫" // Gray circle for unknown
}
status := m.pingManager.GetStatus(hostName)
switch status {
case connectivity.StatusOnline:
return "🟢" // Green circle for online
case connectivity.StatusOffline:
return "🔴" // Red circle for offline
case connectivity.StatusConnecting:
return "🟡" // Yellow circle for connecting
default:
return "⚫" // Gray circle for unknown
}
}
// extractHostNameFromTableRow extracts the host name from the first column,
// removing the ping status indicator
func extractHostNameFromTableRow(firstColumn string) string {
// The first column format is: "🟢 hostname" or "⚫ hostname" etc.
// We need to remove the emoji and space to get just the hostname
parts := strings.Fields(firstColumn)
if len(parts) >= 2 {
// Return everything after the first part (the emoji)
return strings.Join(parts[1:], " ")
}
// Fallback: if there's no space, return the whole string
return firstColumn
}

View File

@@ -23,6 +23,26 @@ func (m Model) View() string {
if m.editForm != nil {
return m.editForm.View()
}
case ViewMove:
if m.moveForm != nil {
return m.moveForm.View()
}
case ViewInfo:
if m.infoForm != nil {
return m.infoForm.View()
}
case ViewPortForward:
if m.portForwardForm != nil {
return m.portForwardForm.View()
}
case ViewHelp:
if m.helpForm != nil {
return m.helpForm.View()
}
case ViewFileSelector:
if m.fileSelectorForm != nil {
return m.fileSelectorForm.View()
}
case ViewList:
return m.renderListView()
}
@@ -38,6 +58,20 @@ func (m Model) renderListView() string {
// Add the ASCII title
components = append(components, m.styles.Header.Render(asciiTitle))
// Add update notification if available (between title and search)
if m.updateInfo != nil && m.updateInfo.Available {
updateText := fmt.Sprintf("🚀 Update available: %s → %s",
m.updateInfo.CurrentVer,
m.updateInfo.LatestVer)
updateStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("10")). // Green color
Bold(true).
Align(lipgloss.Center) // Center the notification
components = append(components, updateStyle.Render(updateText))
}
// Add the search bar with the appropriate style based on focus
searchPrompt := "Search (/ to focus): "
if m.searchMode {
@@ -46,10 +80,6 @@ func (m Model) renderListView() string {
components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View()))
}
// Add the sort mode indicator
sortInfo := fmt.Sprintf(" Sort: %s", m.sortMode.String())
components = append(components, m.styles.SortInfo.Render(sortInfo))
// Add the table with the appropriate style based on focus
if m.searchMode {
// The table is not focused, use the unfocused style
@@ -62,9 +92,9 @@ func (m Model) renderListView() string {
// Add the help text
var helpText string
if !m.searchMode {
helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit"
helpText = " ↑/↓: navigate • Enter: connect • p: ping all • i: info • h: help • q: quit"
} else {
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit"
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
}
components = append(components, m.styles.HelpText.Render(helpText))
@@ -145,3 +175,30 @@ func (m Model) renderDeleteConfirmation() string {
return box.Render(raw)
}
// renderUpdateNotification renders the update notification banner
func (m Model) renderUpdateNotification() string {
if m.updateInfo == nil || !m.updateInfo.Available {
return ""
}
// Create the notification message
message := fmt.Sprintf("🚀 Update available: %s → %s",
m.updateInfo.CurrentVer,
m.updateInfo.LatestVer)
// Add release URL if available
if m.updateInfo.ReleaseURL != "" {
message += fmt.Sprintf(" • View release: %s", m.updateInfo.ReleaseURL)
}
// Style the notification with a bright color to make it stand out
notificationStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00FF00")). // Bright green
Bold(true).
Padding(0, 1).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#00AA00")) // Darker green border
return notificationStyle.Render(message)
}

View File

@@ -0,0 +1,177 @@
package validation
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateHostname(t *testing.T) {
tests := []struct {
name string
hostname string
want bool
}{
{"valid hostname", "example.com", true},
{"valid IP", "192.168.1.1", true}, // IPs are valid hostnames too
{"valid subdomain", "sub.example.com", true},
{"valid single word", "localhost", true},
{"empty hostname", "", false},
{"hostname too long", strings.Repeat("a", 254), false},
{"hostname with space", "example .com", false},
{"hostname starting with dot", ".example.com", false},
{"hostname ending with dot", "example.com.", false},
{"hostname with hyphen", "my-server.com", true},
{"hostname starting with number", "1example.com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidateHostname(tt.hostname); got != tt.want {
t.Errorf("ValidateHostname(%q) = %v, want %v", tt.hostname, got, tt.want)
}
})
}
}
func TestValidateIP(t *testing.T) {
tests := []struct {
name string
ip string
want bool
}{
{"valid IPv4", "192.168.1.1", true},
{"valid IPv6", "2001:db8::1", true},
{"invalid IP", "256.256.256.256", false},
{"empty IP", "", false},
{"hostname not IP", "example.com", false},
{"localhost", "127.0.0.1", true},
{"zero IP", "0.0.0.0", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidateIP(tt.ip); got != tt.want {
t.Errorf("ValidateIP(%q) = %v, want %v", tt.ip, got, tt.want)
}
})
}
}
func TestValidatePort(t *testing.T) {
tests := []struct {
name string
port string
want bool
}{
{"valid port 22", "22", true},
{"valid port 80", "80", true},
{"valid port 65535", "65535", true},
{"valid port 1", "1", true},
{"empty port", "", true}, // Empty defaults to 22
{"invalid port 0", "0", false},
{"invalid port 65536", "65536", false},
{"invalid port negative", "-1", false},
{"invalid port string", "abc", false},
{"invalid port with space", "22 ", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidatePort(tt.port); got != tt.want {
t.Errorf("ValidatePort(%q) = %v, want %v", tt.port, got, tt.want)
}
})
}
}
func TestValidateHostName(t *testing.T) {
tests := []struct {
name string
hostName string
want bool
}{
{"valid host name", "myserver", true},
{"valid host name with hyphen", "my-server", true},
{"valid host name with number", "server1", true},
{"empty host name", "", false},
{"host name too long", strings.Repeat("a", 51), false},
{"host name with space", "my server", false},
{"host name with tab", "my\tserver", false},
{"host name with newline", "my\nserver", false},
{"host name with hash", "my#server", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidateHostName(tt.hostName); got != tt.want {
t.Errorf("ValidateHostName(%q) = %v, want %v", tt.hostName, got, tt.want)
}
})
}
}
func TestValidateIdentityFile(t *testing.T) {
// Create a temporary file for testing
tmpDir := t.TempDir()
validFile := filepath.Join(tmpDir, "test_key")
if err := os.WriteFile(validFile, []byte("test"), 0600); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
path string
want bool
}{
{"empty path", "", true}, // Optional field
{"valid file", validFile, true},
{"non-existent file", "/path/to/nonexistent", false},
{"tilde path", "~/.ssh/id_rsa", true}, // Will pass if file exists
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidateIdentityFile(tt.path); got != tt.want {
t.Errorf("ValidateIdentityFile(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
}
func TestValidateHost(t *testing.T) {
// Create a temporary file for identity testing
tmpDir := t.TempDir()
validIdentity := filepath.Join(tmpDir, "test_key")
if err := os.WriteFile(validIdentity, []byte("test"), 0600); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
hostName string
hostname string
port string
identity string
wantErr bool
}{
{"valid host", "myserver", "example.com", "22", "", false},
{"valid host with identity", "myserver", "192.168.1.1", "2222", validIdentity, false},
{"empty host name", "", "example.com", "22", "", true},
{"invalid host name", "my server", "example.com", "22", "", true},
{"empty hostname", "myserver", "", "22", "", true},
{"invalid hostname", "myserver", "invalid..hostname", "22", "", true},
{"invalid port", "myserver", "example.com", "99999", "", true},
{"invalid identity", "myserver", "example.com", "22", "/nonexistent", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateHost(tt.hostName, tt.hostname, tt.port, tt.identity)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateHost() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

145
internal/version/version.go Normal file
View File

@@ -0,0 +1,145 @@
package version
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// GitHubRelease represents a GitHub release response
type GitHubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
// UpdateInfo contains information about available updates
type UpdateInfo struct {
Available bool
CurrentVer string
LatestVer string
ReleaseURL string
ReleaseName string
}
// parseVersion extracts version numbers from a version string (e.g., "v1.2.3" -> [1, 2, 3])
func parseVersion(version string) []int {
// Remove 'v' prefix if present
version = strings.TrimPrefix(version, "v")
parts := strings.Split(version, ".")
nums := make([]int, len(parts))
for i, part := range parts {
// Remove any non-numeric suffixes (e.g., "1-beta", "2-rc1")
numPart := strings.FieldsFunc(part, func(r rune) bool {
return r == '-' || r == '+' || r == '_'
})[0]
if num, err := strconv.Atoi(numPart); err == nil {
nums[i] = num
}
}
return nums
}
// compareVersions compares two version strings
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
func compareVersions(v1, v2 string) int {
nums1 := parseVersion(v1)
nums2 := parseVersion(v2)
// Pad with zeros to make lengths equal
maxLen := len(nums1)
if len(nums2) > maxLen {
maxLen = len(nums2)
}
for len(nums1) < maxLen {
nums1 = append(nums1, 0)
}
for len(nums2) < maxLen {
nums2 = append(nums2, 0)
}
// Compare each part
for i := 0; i < maxLen; i++ {
if nums1[i] < nums2[i] {
return -1
}
if nums1[i] > nums2[i] {
return 1
}
}
return 0
}
// CheckForUpdates checks GitHub for the latest release of sshm
func CheckForUpdates(ctx context.Context, currentVersion string) (*UpdateInfo, error) {
// Skip version check if current version is "dev"
if currentVersion == "dev" {
return &UpdateInfo{
Available: false,
CurrentVer: currentVersion,
}, nil
}
// Create HTTP client with timeout
client := &http.Client{
Timeout: 10 * time.Second,
}
// Create request with context
req, err := http.NewRequestWithContext(ctx, "GET",
"https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set user agent
req.Header.Set("User-Agent", "sshm/"+currentVersion)
// Make the request
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch latest release: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}
// Parse the response
var release GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Skip pre-releases and drafts
if release.Prerelease || release.Draft {
return &UpdateInfo{
Available: false,
CurrentVer: currentVersion,
}, nil
}
// Compare versions
updateAvailable := compareVersions(currentVersion, release.TagName) < 0
return &UpdateInfo{
Available: updateAvailable,
CurrentVer: currentVersion,
LatestVer: release.TagName,
ReleaseURL: release.HTMLURL,
ReleaseName: release.Name,
}, nil
}

View File

@@ -0,0 +1,56 @@
package version
import (
"testing"
)
func TestParseVersion(t *testing.T) {
tests := []struct {
version string
expected []int
}{
{"v1.2.3", []int{1, 2, 3}},
{"1.2.3", []int{1, 2, 3}},
{"v2.0.0", []int{2, 0, 0}},
{"1.2.3-beta", []int{1, 2, 3}},
{"1.2.3-rc1", []int{1, 2, 3}},
{"dev", []int{0}},
}
for _, test := range tests {
result := parseVersion(test.version)
if len(result) != len(test.expected) {
t.Errorf("parseVersion(%q) length = %d, want %d", test.version, len(result), len(test.expected))
continue
}
for i, v := range result {
if v != test.expected[i] {
t.Errorf("parseVersion(%q)[%d] = %d, want %d", test.version, i, v, test.expected[i])
break
}
}
}
}
func TestCompareVersions(t *testing.T) {
tests := []struct {
v1 string
v2 string
expected int
}{
{"v1.0.0", "v1.0.1", -1},
{"v1.0.1", "v1.0.0", 1},
{"v1.0.0", "v1.0.0", 0},
{"1.2.3", "1.2.4", -1},
{"2.0.0", "1.9.9", 1},
{"1.2.3-beta", "1.2.3", 0}, // Should ignore suffixes
{"1.2.3", "1.2.3-rc1", 0},
}
for _, test := range tests {
result := compareVersions(test.v1, test.v2)
if result != test.expected {
t.Errorf("compareVersions(%q, %q) = %d, want %d", test.v1, test.v2, result, test.expected)
}
}
}

View File

@@ -1,6 +1,6 @@
package main
import "sshm/cmd"
import "github.com/Gu1llaum-3/sshm/cmd"
func main() {
cmd.Execute()