mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
Compare commits
6 Commits
959c084466
...
1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 21c5d41977 | |||
| 20bc506e36 | |||
| e8c6e602a2 | |||
| b5d8d505cf | |||
| 3a72694e5a | |||
| 8f2837db78 |
30
.github/workflows/build.yml
vendored
30
.github/workflows/build.yml
vendored
@@ -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,30 @@ 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 }} .
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }}.exe .
|
||||
else
|
||||
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
|
||||
fi
|
||||
|
||||
- name: Create tarball
|
||||
- name: Create archive
|
||||
run: |
|
||||
cd dist
|
||||
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
|
||||
rm sshm-${{ matrix.suffix }}
|
||||
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,6 +110,7 @@ jobs:
|
||||
run: |
|
||||
mkdir -p release
|
||||
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
|
||||
find ./artifacts -name "*.zip" -exec cp {} ./release/ \;
|
||||
ls -la ./release/
|
||||
|
||||
- name: Create Release
|
||||
|
||||
109
README.md
109
README.md
@@ -9,14 +9,18 @@
|
||||
[](https://golang.org/)
|
||||
[](https://github.com/Gu1llaum-3/sshm/releases)
|
||||
[](LICENSE)
|
||||
[](https://github.com/Gu1llaum-3/sshm/releases)
|
||||
[](https://github.com/Gu1llaum-3/sshm/releases)
|
||||
|
||||
> **A modern, interactive SSH Manager for your terminal** 🔥
|
||||
|
||||
SSHM is a beautiful command-line tool that transforms how you manage and connect to your SSH hosts. Built with Go and featuring an intuitive TUI interface, it makes SSH connection management effortless and enjoyable.
|
||||
|
||||
<p align="center">
|
||||
<img src="images/sshm.gif" alt="Demo SSHM Terminal" width="600" />
|
||||
<a href="images/sshm.gif" target="_blank">
|
||||
<img src="images/sshm.gif" alt="Demo SSHM Terminal" width="800" />
|
||||
</a>
|
||||
<br>
|
||||
<em>🖱️ Click on the image to view in full size</em>
|
||||
</p>
|
||||
|
||||
## ✨ Features
|
||||
@@ -28,6 +32,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
||||
- **🏷️ 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 Options Support** - Add any SSH configuration option through intuitive forms
|
||||
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
|
||||
|
||||
@@ -44,19 +49,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
|
||||
@@ -66,6 +78,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
|
||||
@@ -85,6 +105,14 @@ sshm
|
||||
- `q` - Quit
|
||||
- `/` - Search/filter hosts
|
||||
|
||||
**Sorting & Filtering:**
|
||||
- `s` - Switch between sorting modes (name ↔ last login)
|
||||
- `n` - Sort by **name** (alphabetical)
|
||||
- `r` - Sort by **recent** (last login time)
|
||||
- `Tab` - Cycle between filtering modes
|
||||
- Filter by **name** (default) - Search through host names
|
||||
- Filter by **last login** - Sort and filter by most recently used connections
|
||||
|
||||
The interactive forms will guide you through configuration:
|
||||
- **Hostname/IP** - Server address
|
||||
- **Username** - SSH user
|
||||
@@ -102,15 +130,24 @@ SSHM provides both command-line operations and an interactive TUI interface:
|
||||
# Launch interactive TUI mode for browsing and connecting to hosts
|
||||
sshm
|
||||
|
||||
# Launch TUI with custom SSH config file
|
||||
sshm -c /path/to/custom/ssh_config
|
||||
|
||||
# Add a new host using interactive form
|
||||
sshm add
|
||||
|
||||
# Add a new host with pre-filled hostname
|
||||
sshm add hostname
|
||||
|
||||
# Add a new host with custom SSH config file
|
||||
sshm add hostname -c /path/to/custom/ssh_config
|
||||
|
||||
# Edit an existing host configuration
|
||||
sshm edit my-server
|
||||
|
||||
# Edit host with custom SSH config file
|
||||
sshm edit my-server -c /path/to/custom/ssh_config
|
||||
|
||||
# Show version information
|
||||
sshm --version
|
||||
|
||||
@@ -118,6 +155,32 @@ sshm --version
|
||||
sshm --help
|
||||
```
|
||||
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
# Use custom config file in TUI mode
|
||||
sshm -c /path/to/custom/ssh_config
|
||||
|
||||
# Use custom config file with commands
|
||||
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.
|
||||
@@ -222,24 +285,44 @@ go build -o sshm .
|
||||
|
||||
```
|
||||
sshm/
|
||||
├── cmd/ # CLI commands (Cobra)
|
||||
├── main.go # Application entry point
|
||||
├── cmd/ # CLI commands (Cobra)
|
||||
│ ├── root.go # Root command and interactive mode
|
||||
│ ├── add.go # Add host command
|
||||
│ └── edit.go # Edit host command
|
||||
│ ├── edit.go # Edit host command
|
||||
│ └── search.go # Search command
|
||||
├── internal/
|
||||
│ ├── config/ # SSH configuration management
|
||||
│ │ └── ssh.go # Config parsing and manipulation
|
||||
│ ├── ui/ # Terminal UI components
|
||||
│ │ ├── tui.go # Main TUI interface
|
||||
│ │ ├── add_form.go # Add host form
|
||||
│ │ └── edit_form.go# Edit host form
|
||||
│ ├── history/ # Connection history tracking
|
||||
│ │ └── history.go # History management and last login tracking
|
||||
│ ├── ui/ # Terminal UI components (Bubble Tea)
|
||||
│ │ ├── tui.go # Main TUI interface and program setup
|
||||
│ │ ├── model.go # Core TUI model and state
|
||||
│ │ ├── update.go # Message handling and state updates
|
||||
│ │ ├── view.go # UI rendering and layout
|
||||
│ │ ├── table.go # Host list table component
|
||||
│ │ ├── add_form.go # Add host form interface
|
||||
│ │ ├── edit_form.go# Edit host form interface
|
||||
│ │ ├── styles.go # Lip Gloss styling definitions
|
||||
│ │ ├── sort.go # Sorting and filtering logic
|
||||
│ │ └── utils.go # UI utility functions
|
||||
│ └── validation/ # Input validation
|
||||
│ └── ssh.go # SSH config validation
|
||||
├── images/ # Documentation assets
|
||||
│ ├── logo.png # Project logo
|
||||
│ └── sshm.gif # Demo animation
|
||||
├── install/ # Installation scripts
|
||||
│ ├── unix.sh # Unix/Linux/macOS installer
|
||||
│ └── README.md # Installation guide
|
||||
└── .github/workflows/ # CI/CD pipelines
|
||||
└── build.yml # Multi-platform builds
|
||||
├── .github/ # GitHub configuration
|
||||
│ ├── copilot-instructions.md # Development guidelines
|
||||
│ └── workflows/ # CI/CD pipelines
|
||||
│ └── build.yml # Multi-platform builds
|
||||
├── go.mod # Go module definition
|
||||
├── go.sum # Go module checksums
|
||||
├── LICENSE # MIT license
|
||||
└── README.md # Project documentation
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
@@ -259,6 +342,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
|
||||
|
||||
|
||||
11
cmd/root.go
11
cmd/root.go
@@ -21,9 +21,14 @@ var configFile string
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "sshm",
|
||||
Short: "SSH Manager - A modern SSH connection manager",
|
||||
Long: `SSH Manager (sshm) is a modern command-line tool for managing SSH connections.
|
||||
It provides an interactive interface to browse and connect to your SSH hosts
|
||||
configured in your ~/.ssh/config file.`,
|
||||
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.
|
||||
|
||||
You can also use sshm in CLI mode for direct operations.
|
||||
|
||||
Hosts are read from your ~/.ssh/config file by default.`,
|
||||
Version: version,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// If no arguments provided, run interactive mode
|
||||
|
||||
BIN
images/sshm.gif
BIN
images/sshm.gif
Binary file not shown.
|
Before Width: | Height: | Size: 615 KiB After Width: | Height: | Size: 797 KiB |
@@ -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)"
|
||||
|
||||
135
install/windows.ps1
Normal file
135
install/windows.ps1
Normal 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
|
||||
}
|
||||
11
internal/config/permissions_unix.go
Normal file
11
internal/config/permissions_unix.go
Normal 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)
|
||||
}
|
||||
24
internal/config/permissions_windows.go
Normal file
24
internal/config/permissions_windows.go
Normal 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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -22,6 +23,46 @@ type SSHHost struct {
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -46,12 +87,10 @@ func backupConfig(configPath string) error {
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -59,18 +98,22 @@ func ParseSSHConfig() ([]SSHHost, error) {
|
||||
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||
// Check if the file exists, otherwise create it (and the parent directory if needed)
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
dir := filepath.Dir(configPath)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
err = os.MkdirAll(dir, 0700)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
||||
}
|
||||
// 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()
|
||||
|
||||
// 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 created, return empty host list
|
||||
return []SSHHost{}, nil
|
||||
}
|
||||
@@ -181,11 +224,10 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -404,11 +446,10 @@ 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()
|
||||
configPath, err := GetDefaultSSHConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||
return UpdateSSHHostInFile(oldName, newHost, configPath)
|
||||
}
|
||||
|
||||
@@ -564,11 +605,10 @@ 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()
|
||||
configPath, err := GetDefaultSSHConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||
return DeleteSSHHostFromFile(hostName, configPath)
|
||||
}
|
||||
|
||||
|
||||
73
internal/config/ssh_test.go
Normal file
73
internal/config/ssh_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -130,4 +130,119 @@ func (m *Model) updateTableRows() {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -99,21 +99,12 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
})
|
||||
}
|
||||
|
||||
// Determine table height: 1 (header) + number of hosts (max 10)
|
||||
hostCount := len(rows)
|
||||
tableHeight := 1 // header
|
||||
if hostCount < 10 {
|
||||
tableHeight += hostCount
|
||||
} else {
|
||||
tableHeight += 10
|
||||
}
|
||||
|
||||
// Create the table
|
||||
// Create the table with initial height (will be updated on first WindowSizeMsg)
|
||||
t := table.New(
|
||||
table.WithColumns(columns),
|
||||
table.WithRows(rows),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(tableHeight),
|
||||
table.WithHeight(10), // Initial height, will be recalculated dynamically
|
||||
)
|
||||
|
||||
// Style the table
|
||||
@@ -135,6 +126,9 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
// Initialize table styles based on initial focus state
|
||||
m.updateTableStyles()
|
||||
|
||||
// The table height will be properly set on the first WindowSizeMsg
|
||||
// when m.ready becomes true and actual terminal dimensions are known
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.styles = NewStyles(m.width)
|
||||
m.ready = true
|
||||
|
||||
// Update table height and columns based on new window size
|
||||
m.updateTableHeight()
|
||||
m.updateTableColumns()
|
||||
|
||||
// Update sub-forms if they exist
|
||||
if m.addForm != nil {
|
||||
m.addForm.width = m.width
|
||||
@@ -66,7 +70,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.hosts = m.sortHosts(hosts)
|
||||
m.filteredHosts = m.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.addForm = nil
|
||||
@@ -103,7 +114,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.hosts = m.sortHosts(hosts)
|
||||
m.filteredHosts = m.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.editForm = nil
|
||||
@@ -230,7 +248,14 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.hosts = m.sortHosts(hosts)
|
||||
m.filteredHosts = m.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.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
|
||||
@@ -39,7 +39,7 @@ func (m Model) renderListView() string {
|
||||
components = append(components, m.styles.Header.Render(asciiTitle))
|
||||
|
||||
// Add the search bar with the appropriate style based on focus
|
||||
searchPrompt := "Search (/ to focus, Tab to switch): "
|
||||
searchPrompt := "Search (/ to focus): "
|
||||
if m.searchMode {
|
||||
components = append(components, m.styles.SearchFocused.Render(searchPrompt+m.searchInput.View()))
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user