From 20bc506e360cbe39e48120df78c1d5be41c176ef Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Wed, 3 Sep 2025 17:48:58 +0200 Subject: [PATCH] feat: add Windows platform support --- .github/workflows/build.yml | 30 +++++-- README.md | 36 ++++++++- install/README.md | 20 +++++ install/windows.ps1 | 107 +++++++++++++++++++++++++ internal/config/permissions_unix.go | 11 +++ internal/config/permissions_windows.go | 24 ++++++ internal/config/ssh.go | 70 ++++++++++++---- internal/config/ssh_test.go | 73 +++++++++++++++++ 8 files changed, 348 insertions(+), 23 deletions(-) create mode 100644 install/windows.ps1 create mode 100644 internal/config/permissions_unix.go create mode 100644 internal/config/permissions_windows.go create mode 100644 internal/config/ssh_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5077e92..9ded6f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/README.md b/README.md index fc5a749..6757196 100644 --- a/README.md +++ b/README.md @@ -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** 🔥 @@ -49,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 @@ -71,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 @@ -153,6 +168,19 @@ 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. @@ -314,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 diff --git a/install/README.md b/install/README.md index fb47ac9..50848ac 100644 --- a/install/README.md +++ b/install/README.md @@ -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)" diff --git a/install/windows.ps1 b/install/windows.ps1 new file mode 100644 index 0000000..8ef93e3 --- /dev/null +++ b/install/windows.ps1 @@ -0,0 +1,107 @@ +# SSHM Windows Installation Script +# Usage: irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex + +param( + [string]$InstallDir = "$env:USERPROFILE\bin", + [switch]$Force = $false +) + +$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)" + +# 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 "Latest version: $latestVersion" +} catch { + Write-Error "Failed to fetch latest version" + 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 +} diff --git a/internal/config/permissions_unix.go b/internal/config/permissions_unix.go new file mode 100644 index 0000000..e21459d --- /dev/null +++ b/internal/config/permissions_unix.go @@ -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) +} diff --git a/internal/config/permissions_windows.go b/internal/config/permissions_windows.go new file mode 100644 index 0000000..1a568d7 --- /dev/null +++ b/internal/config/permissions_windows.go @@ -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 +} diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 061c998..6a4896c 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -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) } diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go new file mode 100644 index 0000000..1256816 --- /dev/null +++ b/internal/config/ssh_test.go @@ -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) + } +}