commit fad2585d5e6ecccefd5ceabb9f51a824d1601b7d Author: Gu1llaum-3 Date: Sun Aug 31 22:57:23 2025 +0200 first commit diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..5077e92 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,104 @@ +name: Build Binaries + +on: + push: + tags: + - '*' + release: + types: [created] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: Build + runs-on: ubuntu-latest + strategy: + matrix: + include: + # Linux AMD64 + - goos: linux + goarch: amd64 + suffix: linux-amd64 + # Linux ARM64 + - goos: linux + goarch: arm64 + suffix: linux-arm64 + # macOS AMD64 (Intel) + - goos: darwin + goarch: amd64 + suffix: darwin-amd64 + # macOS ARM64 (Apple Silicon) + - goos: darwin + goarch: arm64 + suffix: darwin-arm64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + mkdir -p dist + VERSION=${GITHUB_REF#refs/tags/} + go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} . + + - name: Create tarball + run: | + cd dist + tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }} + rm sshm-${{ matrix.suffix }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: sshm-${{ matrix.suffix }} + path: dist/sshm-${{ matrix.suffix }}.tar.gz + + release: + name: Create Release + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: ./artifacts + merge-multiple: true + + - name: Prepare release assets + run: | + mkdir -p release + find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \; + ls -la ./release/ + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: ./release/* + draft: false + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c4bd6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Build artifacts +dist/ +build/ +bin/ + +# Temporary files +*.tmp +*.temp +temp/ + +# Log files +*.log + +# Project specific +sshm +sshm-* +!sshm-test +release/ + +# Backup files +*.backup +*.bak + +# Local development files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..469f0f0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Guillaume Archambault + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4560e5 --- /dev/null +++ b/README.md @@ -0,0 +1,293 @@ + + +

+ SSHM Logo +

+ +# ๐Ÿš€ SSHM - SSH Manager + +[![Go](https://img.shields.io/badge/Go-1.23+-00ADD8?style=for-the-badge&logo=go)](https://golang.org/) +[![Release](https://img.shields.io/github/v/release/Gu1llaum-3/sshm?style=for-the-badge)](https://github.com/Gu1llaum-3/sshm/releases) +[![License](https://img.shields.io/github/license/Gu1llaum-3/sshm?style=for-the-badge)](LICENSE) +[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS-lightgrey?style=for-the-badge)](https://github.com/Gu1llaum-3/sshm/releases) + +> **A modern, interactive SSH Manager for your terminal** ๐Ÿ”ฅ + +SSHM is a beautiful command-line tool that transforms how you manage and connect to your SSH hosts. Built with Go and featuring an intuitive TUI interface, it makes SSH connection management effortless and enjoyable. + +

+ Demo SSHM Terminal +

+ +## โœจ Features + +### ๐ŸŽฏ **Core Features** +- **๐ŸŽจ Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI +- **โšก Quick Connect** - Connect to any host instantly +- **๐Ÿ“ Easy Management** - Add, edit, and manage SSH configurations seamlessly +- **๐Ÿท๏ธ Tag Support** - Organize your hosts with custom tags for better categorization +- **๐Ÿ” Smart Search** - Find hosts quickly with built-in filtering and search +- **๐Ÿ”’ Secure** - Works directly with your existing `~/.ssh/config` file +- **โš™๏ธ SSH Options Support** - Add any SSH configuration option through intuitive forms +- **๐Ÿ”„ Automatic Conversion** - Seamlessly converts between command-line and config formats + +### ๐Ÿ› ๏ธ **Management Operations** +- **Add new SSH hosts** with interactive forms +- **Edit existing configurations** in-place +- **Delete hosts** with confirmation prompts +- **Backup configurations** automatically before changes +- **Validate settings** to prevent configuration errors +- **ProxyJump support** for secure connection tunneling through bastion hosts +- **SSH Options management** - Add any SSH option with automatic format conversion +- **Full SSH compatibility** - Maintains compatibility with standard SSH tools + +### ๐ŸŽฎ **User Experience** +- **Zero configuration** - Works out of the box with your existing SSH setup +- **Keyboard shortcuts** for power users +- **Cross-platform** - Supports Linux and macOS (Intel & Apple Silicon) +- **Lightweight** - Single binary with no dependencies + +## ๐Ÿš€ Quick Start + +### Installation + +**One-line install (Recommended):** +```bash +curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash +``` + +**Alternative methods:** +```bash +# Download specific release +wget https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz + +# Extract and install +tar -xzf sshm-linux-amd64.tar.gz +sudo mv sshm-linux-amd64 /usr/local/bin/sshm +``` + +## ๐Ÿ“– Usage + +### Interactive Mode + +Launch SSHM without arguments to enter the beautiful TUI interface: + +```bash +sshm +``` + +**Navigation:** +- `โ†‘/โ†“` or `j/k` - Navigate hosts +- `Enter` - Connect to selected host +- `a` - Add new host +- `e` - Edit selected host +- `d` - Delete selected host +- `q` - Quit +- `/` - Search/filter hosts + +The interactive forms will guide you through configuration: +- **Hostname/IP** - Server address +- **Username** - SSH user +- **Port** - SSH port (default: 22) +- **Identity File** - Private key path +- **ProxyJump** - Jump server for connection tunneling +- **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`) +- **Tags** - Comma-separated tags for organization + +### CLI Usage + +SSHM provides both command-line operations and an interactive TUI interface: + +```bash +# Launch interactive TUI mode for browsing and connecting to hosts +sshm + +# Add a new host using interactive form +sshm add + +# Add a new host with pre-filled hostname +sshm add hostname + +# Edit an existing host configuration +sshm edit my-server + +# Show version information +sshm --version + +# Show help and available commands +sshm --help +``` + +## ๐Ÿ—๏ธ Configuration + +SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools. + +**Example configuration:** +```ssh +# Tags: production, web, frontend +Host web-prod-01 + HostName 192.168.1.10 + User deploy + Port 22 + IdentityFile ~/.ssh/production_key + Compression yes + ServerAliveInterval 60 + +# Tags: development, database +Host db-dev + HostName dev-db.company.com + User admin + Port 2222 + IdentityFile ~/.ssh/dev_key + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + +# Tags: production, backend +Host backend-prod + HostName 10.0.1.50 + User app + Port 22 + ProxyJump bastion.company.com + IdentityFile ~/.ssh/production_key + Compression yes + ServerAliveInterval 300 + BatchMode yes +``` + +### Supported SSH Options + +SSHM supports all standard SSH configuration options: + +**Built-in Fields:** +- `HostName` - Server hostname or IP address +- `User` - Username for SSH connection +- `Port` - SSH port number +- `IdentityFile` - Path to private key file +- `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`) +- `Tags` - Custom tags (SSHM extension) + +**Additional SSH Options:** +You can add any valid SSH option using the "SSH Options" field in the interactive forms. Enter them in command-line format (e.g., `-o Compression=yes -o ServerAliveInterval=60`) and SSHM will automatically convert them to the proper SSH config format. + +**Common SSH Options:** +- `Compression` - Enable/disable compression (`yes`/`no`) +- `ServerAliveInterval` - Interval in seconds for keepalive messages +- `ServerAliveCountMax` - Maximum number of keepalive messages +- `StrictHostKeyChecking` - Host key verification (`yes`/`no`/`ask`) +- `UserKnownHostsFile` - Path to known hosts file +- `BatchMode` - Disable interactive prompts (`yes`/`no`) +- `ConnectTimeout` - Connection timeout in seconds +- `ControlMaster` - Connection multiplexing (`yes`/`no`/`auto`) +- `ControlPath` - Path for control socket +- `ControlPersist` - Keep connection alive duration +- `ForwardAgent` - Forward SSH agent (`yes`/`no`) +- `LocalForward` - Local port forwarding (e.g., `8080:localhost:80`) +- `RemoteForward` - Remote port forwarding +- `DynamicForward` - SOCKS proxy port forwarding + +**Example usage in forms:** +``` +SSH Options: -o Compression=yes -o ServerAliveInterval=60 -o StrictHostKeyChecking=no +``` + +This will be automatically converted to: +```ssh + Compression yes + ServerAliveInterval 60 + StrictHostKeyChecking no +``` + +## ๐Ÿ› ๏ธ Development + +### Prerequisites + +- Go 1.23+ +- Git + +### Build from Source + +```bash +# Clone the repository +git clone https://github.com/Gu1llaum-3/sshm.git +cd sshm + +# Build the binary +go build -o sshm . + +# Run +./sshm +``` + +### Project Structure + +``` +sshm/ +โ”œโ”€โ”€ cmd/ # CLI commands (Cobra) +โ”‚ โ”œโ”€โ”€ root.go # Root command and interactive mode +โ”‚ โ”œโ”€โ”€ add.go # Add host command +โ”‚ โ””โ”€โ”€ edit.go # Edit host command +โ”œโ”€โ”€ internal/ +โ”‚ โ”œโ”€โ”€ config/ # SSH configuration management +โ”‚ โ”‚ โ””โ”€โ”€ ssh.go # Config parsing and manipulation +โ”‚ โ”œโ”€โ”€ ui/ # Terminal UI components +โ”‚ โ”‚ โ”œโ”€โ”€ tui.go # Main TUI interface +โ”‚ โ”‚ โ”œโ”€โ”€ add_form.go # Add host form +โ”‚ โ”‚ โ””โ”€โ”€ edit_form.go# Edit host form +โ”‚ โ””โ”€โ”€ validation/ # Input validation +โ”‚ โ””โ”€โ”€ ssh.go # SSH config validation +โ”œโ”€โ”€ install/ # Installation scripts +โ”‚ โ”œโ”€โ”€ unix.sh # Unix/Linux/macOS installer +โ”‚ โ””โ”€โ”€ README.md # Installation guide +โ””โ”€โ”€ .github/workflows/ # CI/CD pipelines + โ””โ”€โ”€ build.yml # Multi-platform builds +``` + +### Dependencies + +- [Cobra](https://github.com/spf13/cobra) - CLI framework +- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework +- [Bubbles](https://github.com/charmbracelet/bubbles) - TUI components +- [Lipgloss](https://github.com/charmbracelet/lipgloss) - Styling + +## ๐Ÿ“ฆ Releases + +Automated releases are built for multiple platforms: + +| Platform | Architecture | Download | +|----------|-------------|----------| +| Linux | AMD64 | [sshm-linux-amd64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz) | +| Linux | ARM64 | [sshm-linux-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-arm64.tar.gz) | +| macOS | Intel | [sshm-darwin-amd64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-amd64.tar.gz) | +| macOS | Apple Silicon | [sshm-darwin-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-arm64.tar.gz) | + +## ๐Ÿค Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. + +### Development Workflow + +1. **Fork** the repository +2. **Create** a feature branch (`git checkout -b feature/amazing-feature`) +3. **Commit** your changes (`git commit -m 'Add amazing feature'`) +4. **Push** to the branch (`git push origin feature/amazing-feature`) +5. **Open** a Pull Request + +## ๐Ÿ“ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## ๐Ÿ™ Acknowledgments + +- [Charm](https://charm.sh/) for the amazing TUI libraries +- [Cobra](https://cobra.dev/) for the excellent CLI framework +- The Go community for building such fantastic tools + +--- + +
+ +**Made with โค๏ธ by [Guillaume](https://github.com/Gu1llaum-3)** + +โญ **Star this repo if you found it useful!** โญ + +
diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..a91c9f7 --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "fmt" + "sshm/internal/ui" + + "github.com/spf13/cobra" +) + +var addCmd = &cobra.Command{ + Use: "add [hostname]", + Short: "Add a new SSH host configuration", + Long: `Add a new SSH host configuration with an interactive form.`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var hostname string + if len(args) > 0 { + hostname = args[0] + } + + err := ui.RunAddForm(hostname) + if err != nil { + fmt.Printf("Error adding host: %v\n", err) + } + }, +} + +func init() { + rootCmd.AddCommand(addCmd) +} diff --git a/cmd/edit.go b/cmd/edit.go new file mode 100644 index 0000000..2f81e7a --- /dev/null +++ b/cmd/edit.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "fmt" + "sshm/internal/ui" + + "github.com/spf13/cobra" +) + +var editCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit an existing SSH host configuration", + Long: `Edit an existing SSH host configuration with an interactive form.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + hostname := args[0] + + err := ui.RunEditForm(hostname) + if err != nil { + fmt.Printf("Error editing host: %v\n", err) + } + }, +} + +func init() { + rootCmd.AddCommand(editCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..8eba8ea --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "fmt" + "log" + "os" + + "sshm/internal/config" + "sshm/internal/ui" + + "github.com/spf13/cobra" +) + +// version will be set at build time via -ldflags +var version = "dev" + +var rootCmd = &cobra.Command{ + Use: "sshm", + Short: "SSH Manager - A modern SSH connection manager", + Long: `SSH Manager (sshm) is a modern command-line tool for managing SSH connections. +It provides an interactive interface to browse and connect to your SSH hosts +configured in your ~/.ssh/config file.`, + Version: version, + Run: func(cmd *cobra.Command, args []string) { + // If no arguments provided, run interactive mode + if len(args) == 0 { + runInteractiveMode() + return + } + + // If a host name is provided, connect directly + hostName := args[0] + connectToHost(hostName) + }, +} + +func runInteractiveMode() { + // Parse SSH configurations + hosts, err := config.ParseSSHConfig() + if err != nil { + log.Fatalf("Error reading SSH config file: %v", err) + } + + if len(hosts) == 0 { + fmt.Println("No SSH hosts found in your ~/.ssh/config file.") + fmt.Print("Would you like to add a new host now? [y/N]: ") + var response string + _, err := fmt.Scanln(&response) + if err == nil && (response == "y" || response == "Y") { + err := ui.RunAddForm("") + if err != nil { + fmt.Printf("Error adding host: %v\n", err) + } + // After adding, try to reload hosts and continue if any exist + hosts, err = config.ParseSSHConfig() + if err != nil || len(hosts) == 0 { + fmt.Println("No hosts available, exiting.") + os.Exit(1) + } + } else { + fmt.Println("No hosts available, exiting.") + os.Exit(1) + } + } + + // Run the interactive TUI + if err := ui.RunInteractiveMode(hosts); err != nil { + log.Fatalf("Error running interactive mode: %v", err) + } +} + +func connectToHost(hostName string) { + // Parse SSH configurations to verify host exists + hosts, err := config.ParseSSHConfig() + if err != nil { + log.Fatalf("Error reading SSH config file: %v", err) + } + + // Check if host exists + var hostFound bool + for _, host := range hosts { + if host.Name == hostName { + hostFound = true + break + } + } + + if !hostFound { + fmt.Printf("Error: Host '%s' not found in SSH configuration.\n", hostName) + fmt.Println("Use 'sshm' to see available hosts.") + os.Exit(1) + } + + // Connect to the host + fmt.Printf("Connecting to %s...\n", hostName) + // Note: In a real implementation, you'd use exec.Command here + // For now, just print the command that would be executed + fmt.Printf("ssh %s\n", hostName) +} + +// Execute adds all child commands to the root command and sets flags appropriately. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5415504 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module sshm + +go 1.23.1 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/cobra v1.9.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..752eedd --- /dev/null +++ b/go.sum @@ -0,0 +1,63 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000..ed1e776 Binary files /dev/null and b/images/logo.png differ diff --git a/images/sshm.gif b/images/sshm.gif new file mode 100644 index 0000000..61e8e0f Binary files /dev/null and b/images/sshm.gif differ diff --git a/install/README.md b/install/README.md new file mode 100644 index 0000000..fb47ac9 --- /dev/null +++ b/install/README.md @@ -0,0 +1,69 @@ +# Installation Scripts + +This directory contains installation scripts for SSHM. + +## Unix/Linux/macOS Installation + +### Quick Install (Recommended) + +```bash +curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash +``` + +**Note:** When using the pipe method, the installer will automatically proceed with installation if SSHM is already installed. + +### Install Options + +**Force install without prompts:** +```bash +FORCE_INSTALL=true bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)" +``` + +**Disable auto-install when using pipe:** +```bash +FORCE_INSTALL=false bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)" +``` + +### Manual Install + +1. Download the script: +```bash +curl -O https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh +``` + +2. Make it executable: +```bash +chmod +x unix.sh +``` + +3. Run the installer: +```bash +./unix.sh +``` + +## What the installer does + +1. **Detects your system** - Automatically detects your OS (Linux/macOS) and architecture (AMD64/ARM64) +2. **Fetches latest version** - Gets the latest release from GitHub +3. **Downloads binary** - Downloads the appropriate binary for your system +4. **Installs to /usr/local/bin** - Installs the binary with proper permissions +5. **Verifies installation** - Checks that the installation was successful + +## Supported Platforms + +- **Linux**: AMD64, ARM64 +- **macOS**: AMD64 (Intel), ARM64 (Apple Silicon) + +## Requirements + +- `curl` - for downloading +- `tar` - for extracting archives +- `sudo` access - for installing to `/usr/local/bin` + +## Uninstall + +To uninstall SSHM: + +```bash +sudo rm /usr/local/bin/sshm +``` diff --git a/install/unix.sh b/install/unix.sh new file mode 100644 index 0000000..56c2fbe --- /dev/null +++ b/install/unix.sh @@ -0,0 +1,173 @@ +#!/bin/bash + +INSTALL_DIR="/usr/local/bin" +EXECUTABLE_NAME=sshm +EXECUTABLE_PATH="$INSTALL_DIR/$EXECUTABLE_NAME" +USE_SUDO="false" +OS="" +ARCH="" +FORCE_INSTALL="${FORCE_INSTALL:-false}" + +RED='\033[0;31m' +PURPLE='\033[0;35m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +setSystem() { + ARCH=$(uname -m) + case $ARCH in + i386|i686) ARCH="amd64" ;; + x86_64) ARCH="amd64";; + armv6*) ARCH="arm64" ;; + armv7*) ARCH="arm64" ;; + aarch64*) ARCH="arm64" ;; + arm64) ARCH="arm64" ;; + esac + + OS=$(echo `uname`|tr '[:upper:]' '[:lower:]') + + # Determine if we need sudo + if [ "$OS" = "linux" ]; then + USE_SUDO="true" + fi + if [ "$OS" = "darwin" ]; then + USE_SUDO="true" + fi +} + +runAsRoot() { + local CMD="$*" + if [ "$USE_SUDO" = "true" ]; then + printf "${PURPLE}We need sudo access to install SSHM to $INSTALL_DIR ${NC}\n" + CMD="sudo $CMD" + fi + $CMD +} + +getLatestVersion() { + printf "${YELLOW}Fetching latest version...${NC}\n" + LATEST_VERSION=$(curl -s https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + if [ -z "$LATEST_VERSION" ]; then + printf "${RED}Failed to fetch latest version${NC}\n" + exit 1 + fi + printf "${GREEN}Latest version: $LATEST_VERSION${NC}\n" +} + +downloadBinary() { + GITHUB_FILE="sshm-${OS}-${ARCH}.tar.gz" + GITHUB_URL="https://github.com/Gu1llaum-3/sshm/releases/download/$LATEST_VERSION/$GITHUB_FILE" + + printf "${YELLOW}Downloading $GITHUB_FILE...${NC}\n" + curl -L "$GITHUB_URL" --progress-bar --output "sshm-tmp.tar.gz" + + if [ $? -ne 0 ]; then + printf "${RED}Failed to download binary${NC}\n" + exit 1 + fi + + # Extract the binary + tar -xzf "sshm-tmp.tar.gz" + if [ $? -ne 0 ]; then + printf "${RED}Failed to extract binary${NC}\n" + exit 1 + fi + + # Find the extracted binary + EXTRACTED_BINARY=$(find . -name "sshm-${OS}-${ARCH}" -type f) + if [ -z "$EXTRACTED_BINARY" ]; then + printf "${RED}Could not find extracted binary${NC}\n" + exit 1 + fi + + mv "$EXTRACTED_BINARY" "sshm-tmp" + rm -f "sshm-tmp.tar.gz" +} + +install() { + printf "${YELLOW}Installing SSHM...${NC}\n" + + chmod +x "sshm-tmp" + if [ $? -ne 0 ]; then + printf "${RED}Failed to set permissions${NC}\n" + exit 1 + fi + + runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH" + if [ $? -ne 0 ]; then + printf "${RED}Failed to install binary${NC}\n" + exit 1 + fi +} + +cleanup() { + rm -f "sshm-tmp" "sshm-tmp.tar.gz" "sshm-${OS}-${ARCH}" +} + +checkExisting() { + if command -v sshm >/dev/null 2>&1; then + CURRENT_VERSION=$(sshm --version 2>/dev/null | grep -o 'version.*' | cut -d' ' -f2 || echo "unknown") + printf "${YELLOW}SSHM is already installed (version: $CURRENT_VERSION)${NC}\n" + + # Check if FORCE_INSTALL is set + if [ "$FORCE_INSTALL" = "true" ]; then + printf "${GREEN}Force install enabled, proceeding with installation...${NC}\n" + return + fi + + # Check if running via pipe (stdin is not a terminal) + if [ ! -t 0 ]; then + printf "${YELLOW}Running via pipe - automatically proceeding with installation...${NC}\n" + printf "${YELLOW}Use 'FORCE_INSTALL=false bash -c \"\$(curl -sSL ...)\"' to disable auto-install${NC}\n" + return + fi + + printf "${YELLOW}Do you want to overwrite it? [y/N]: ${NC}" + read -r response + case "$response" in + [yY][eE][sS]|[yY]) + printf "${GREEN}Proceeding with installation...${NC}\n" + ;; + *) + printf "${GREEN}Installation cancelled.${NC}\n" + exit 0 + ;; + esac + fi +} + +main() { + printf "${PURPLE}Installing SSHM - SSH Connection Manager${NC}\n\n" + + # Check if already installed + checkExisting + + # Set up system detection + setSystem + printf "${GREEN}Detected system: $OS ($ARCH)${NC}\n" + + # Get latest version + getLatestVersion + + # Download and install + downloadBinary + install + cleanup + + printf "\n${GREEN}โœ… SSHM was installed successfully to: ${NC}$EXECUTABLE_PATH\n" + printf "${GREEN}You can now use 'sshm' command to manage your SSH connections!${NC}\n\n" + + # Show version + printf "${YELLOW}Verifying installation...${NC}\n" + if command -v sshm >/dev/null 2>&1; then + sshm --version + else + printf "${RED}Warning: 'sshm' command not found in PATH. You may need to restart your terminal or add $INSTALL_DIR to your PATH.${NC}\n" + fi +} + +# Trap to cleanup on exit +trap cleanup EXIT + +main "$@" diff --git a/internal/config/ssh.go b/internal/config/ssh.go new file mode 100644 index 0000000..6e250fd --- /dev/null +++ b/internal/config/ssh.go @@ -0,0 +1,616 @@ +package config + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" +) + +// SSHHost represents an SSH host configuration +type SSHHost struct { + Name string + Hostname string + User string + Port string + Identity string + ProxyJump string + Options string + Tags []string +} + +// configMutex protects SSH config file operations from race conditions +var configMutex sync.Mutex + +// backupConfig creates a backup of the SSH config file +func backupConfig(configPath string) error { + backupPath := configPath + ".backup" + src, err := os.Open(configPath) + if err != nil { + return err + } + defer src.Close() + + dst, err := os.Create(backupPath) + if err != nil { + return err + } + defer dst.Close() + + _, err = io.Copy(dst, src) + return err +} + +// ParseSSHConfig parses the SSH config file and returns the list of hosts +func ParseSSHConfig() ([]SSHHost, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + configPath := filepath.Join(homeDir, ".ssh", "config") + return ParseSSHConfigFile(configPath) +} + +// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts +func ParseSSHConfigFile(configPath string) ([]SSHHost, error) { + // Check if the file exists, otherwise create it (and the parent directory if needed) + if _, err := os.Stat(configPath); os.IsNotExist(err) { + dir := filepath.Dir(configPath) + if _, err := os.Stat(dir); os.IsNotExist(err) { + err = os.MkdirAll(dir, 0700) + if err != nil { + return nil, fmt.Errorf("failed to create .ssh directory: %w", err) + } + } + file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return nil, fmt.Errorf("failed to create SSH config file: %w", err) + } + file.Close() + // File created, return empty host list + return []SSHHost{}, nil + } + + file, err := os.Open(configPath) + if err != nil { + return nil, err + } + defer file.Close() + + var hosts []SSHHost + var currentHost *SSHHost + var pendingTags []string + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Ignore empty lines + if line == "" { + continue + } + + // Check for tags comment + if strings.HasPrefix(line, "# Tags:") { + tagsStr := strings.TrimPrefix(line, "# Tags:") + tagsStr = strings.TrimSpace(tagsStr) + if tagsStr != "" { + // Split tags by comma and trim whitespace + for _, tag := range strings.Split(tagsStr, ",") { + tag = strings.TrimSpace(tag) + if tag != "" { + pendingTags = append(pendingTags, tag) + } + } + } + continue + } + + // Ignore other comments + if strings.HasPrefix(line, "#") { + continue + } + + // Split line into words + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + + key := strings.ToLower(parts[0]) + value := strings.Join(parts[1:], " ") + + switch key { + case "host": + // New host, save previous one if it exists + if currentHost != nil { + hosts = append(hosts, *currentHost) + } + // Create new host + currentHost = &SSHHost{ + Name: value, + Port: "22", // Default port + Tags: pendingTags, // Assign pending tags to this host + } + // Clear pending tags for next host + pendingTags = nil + case "hostname": + if currentHost != nil { + currentHost.Hostname = value + } + case "user": + if currentHost != nil { + currentHost.User = value + } + case "port": + if currentHost != nil { + currentHost.Port = value + } + case "identityfile": + if currentHost != nil { + currentHost.Identity = value + } + case "proxyjump": + if currentHost != nil { + currentHost.ProxyJump = value + } + default: + // Handle other SSH options + if currentHost != nil && strings.TrimSpace(line) != "" { + // Store options in config format (key value), not command format + if currentHost.Options == "" { + currentHost.Options = parts[0] + " " + value + } else { + currentHost.Options += "\n" + parts[0] + " " + value + } + } + } + } + + // Add the last host if it exists + if currentHost != nil { + hosts = append(hosts, *currentHost) + } + + return hosts, scanner.Err() +} + +// AddSSHHost adds a new SSH host to the config file +func AddSSHHost(host SSHHost) error { + configMutex.Lock() + defer configMutex.Unlock() + + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + + configPath := filepath.Join(homeDir, ".ssh", "config") + + // Create backup before modification if file exists + if _, err := os.Stat(configPath); err == nil { + if err := backupConfig(configPath); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + } + + // Check if host already exists + exists, err := HostExists(host.Name) + if err != nil { + return err + } + if exists { + return fmt.Errorf("host '%s' already exists", host.Name) + } + + // Open file in append mode + file, err := os.OpenFile(configPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer file.Close() + + // Write the configuration + _, err = file.WriteString("\n") + if err != nil { + return err + } + + // Write tags if present + if len(host.Tags) > 0 { + _, err = file.WriteString("# Tags: " + strings.Join(host.Tags, ", ") + "\n") + if err != nil { + return err + } + } + + // Write host configuration + _, err = file.WriteString(fmt.Sprintf("Host %s\n", host.Name)) + if err != nil { + return err + } + + _, err = file.WriteString(fmt.Sprintf(" HostName %s\n", host.Hostname)) + if err != nil { + return err + } + + if host.User != "" { + _, err = file.WriteString(fmt.Sprintf(" User %s\n", host.User)) + if err != nil { + return err + } + } + + if host.Port != "" && host.Port != "22" { + _, err = file.WriteString(fmt.Sprintf(" Port %s\n", host.Port)) + if err != nil { + return err + } + } + + if host.Identity != "" { + _, err = file.WriteString(fmt.Sprintf(" IdentityFile %s\n", host.Identity)) + if err != nil { + return err + } + } + + if host.ProxyJump != "" { + _, err = file.WriteString(fmt.Sprintf(" ProxyJump %s\n", host.ProxyJump)) + if err != nil { + return err + } + } + + // Write SSH options + if host.Options != "" { + // Split options by newlines and write each one + options := strings.Split(host.Options, "\n") + for _, option := range options { + option = strings.TrimSpace(option) + if option != "" { + _, err = file.WriteString(fmt.Sprintf(" %s\n", option)) + if err != nil { + return err + } + } + } + } + + return nil +} + +// ParseSSHOptionsFromCommand converts SSH command line options to config format +// Input: "-o Compression=yes -o ServerAliveInterval=60" +// Output: "Compression yes\nServerAliveInterval 60" +func ParseSSHOptionsFromCommand(options string) string { + if options == "" { + return "" + } + + var result []string + parts := strings.Split(options, "-o") + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + // Replace = with space for SSH config format + option := strings.ReplaceAll(part, "=", " ") + result = append(result, option) + } + + return strings.Join(result, "\n") +} + +// FormatSSHOptionsForCommand converts SSH config options to command line format +// Input: "Compression yes\nServerAliveInterval 60" +// Output: "-o Compression=yes -o ServerAliveInterval=60" +func FormatSSHOptionsForCommand(options string) string { + if options == "" { + return "" + } + + var result []string + lines := strings.Split(options, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Replace space with = for command line format + parts := strings.SplitN(line, " ", 2) + if len(parts) == 2 { + result = append(result, fmt.Sprintf("-o %s=%s", parts[0], parts[1])) + } else { + result = append(result, fmt.Sprintf("-o %s", line)) + } + } + + return strings.Join(result, " ") +} + +// HostExists checks if a host already exists in the config +func HostExists(hostName string) (bool, error) { + hosts, err := ParseSSHConfig() + if err != nil { + return false, err + } + + for _, host := range hosts { + if host.Name == hostName { + return true, nil + } + } + return false, nil +} + +// GetSSHHost retrieves a specific host configuration by name +func GetSSHHost(hostName string) (*SSHHost, error) { + hosts, err := ParseSSHConfig() + if err != nil { + return nil, err + } + + for _, host := range hosts { + if host.Name == hostName { + return &host, nil + } + } + return nil, fmt.Errorf("host '%s' not found", hostName) +} + +// UpdateSSHHost updates an existing SSH host configuration +func UpdateSSHHost(oldName string, newHost SSHHost) error { + configMutex.Lock() + defer configMutex.Unlock() + + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + + configPath := filepath.Join(homeDir, ".ssh", "config") + + // Create backup before modification + if err := backupConfig(configPath); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + + // Read the current config + content, err := os.ReadFile(configPath) + if err != nil { + return err + } + + lines := strings.Split(string(content), "\n") + var newLines []string + i := 0 + hostFound := false + + for i < len(lines) { + line := strings.TrimSpace(lines[i]) + + // Check for tags comment followed by Host + if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) { + nextLine := strings.TrimSpace(lines[i+1]) + if nextLine == "Host "+oldName { + // Found the host to update, skip the old configuration + hostFound = true + + // Skip until we find the end of this host block (empty line or next Host) + i += 2 // Skip tags and Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + + // Skip any trailing empty lines after the host block + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + + // Insert new configuration at this position + // Add empty line only if the previous line is not empty + if len(newLines) > 0 && strings.TrimSpace(newLines[len(newLines)-1]) != "" { + newLines = append(newLines, "") + } + if len(newHost.Tags) > 0 { + newLines = append(newLines, "# Tags: "+strings.Join(newHost.Tags, ", ")) + } + newLines = append(newLines, "Host "+newHost.Name) + newLines = append(newLines, " HostName "+newHost.Hostname) + if newHost.User != "" { + newLines = append(newLines, " User "+newHost.User) + } + if newHost.Port != "" && newHost.Port != "22" { + newLines = append(newLines, " Port "+newHost.Port) + } + if newHost.Identity != "" { + newLines = append(newLines, " IdentityFile "+newHost.Identity) + } + if newHost.ProxyJump != "" { + newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) + } + // Write SSH options + if newHost.Options != "" { + options := strings.Split(newHost.Options, "\n") + for _, option := range options { + option = strings.TrimSpace(option) + if option != "" { + newLines = append(newLines, " "+option) + } + } + } + + // Add empty line after the host configuration for separation + newLines = append(newLines, "") + + continue + } + } + + // Check for Host line without tags + if strings.HasPrefix(line, "Host ") && strings.Fields(line)[1] == oldName { + hostFound = true + + // Skip until we find the end of this host block + i++ // Skip Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + + // Skip any trailing empty lines after the host block + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + + // Insert new configuration + // Add empty line only if the previous line is not empty + if len(newLines) > 0 && strings.TrimSpace(newLines[len(newLines)-1]) != "" { + newLines = append(newLines, "") + } + if len(newHost.Tags) > 0 { + newLines = append(newLines, "# Tags: "+strings.Join(newHost.Tags, ", ")) + } + newLines = append(newLines, "Host "+newHost.Name) + newLines = append(newLines, " HostName "+newHost.Hostname) + if newHost.User != "" { + newLines = append(newLines, " User "+newHost.User) + } + if newHost.Port != "" && newHost.Port != "22" { + newLines = append(newLines, " Port "+newHost.Port) + } + if newHost.Identity != "" { + newLines = append(newLines, " IdentityFile "+newHost.Identity) + } + if newHost.ProxyJump != "" { + newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) + } + // Write SSH options + if newHost.Options != "" { + options := strings.Split(newHost.Options, "\n") + for _, option := range options { + option = strings.TrimSpace(option) + if option != "" { + newLines = append(newLines, " "+option) + } + } + } + + // Add empty line after the host configuration for separation + newLines = append(newLines, "") + + continue + } + + // Keep other lines as-is + newLines = append(newLines, lines[i]) + i++ + } + + if !hostFound { + return fmt.Errorf("host '%s' not found", oldName) + } + + // Write back to file + newContent := strings.Join(newLines, "\n") + return os.WriteFile(configPath, []byte(newContent), 0600) +} + +// DeleteSSHHost removes an SSH host configuration from the config file +func DeleteSSHHost(hostName string) error { + configMutex.Lock() + defer configMutex.Unlock() + + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + + configPath := filepath.Join(homeDir, ".ssh", "config") + + // Create backup before modification + if err := backupConfig(configPath); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + + // Read the current config + content, err := os.ReadFile(configPath) + if err != nil { + return err + } + + lines := strings.Split(string(content), "\n") + var newLines []string + i := 0 + hostFound := false + + for i < len(lines) { + line := strings.TrimSpace(lines[i]) + + // Check for tags comment followed by Host + if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) { + nextLine := strings.TrimSpace(lines[i+1]) + if nextLine == "Host "+hostName { + // Found the host to delete, skip the configuration + hostFound = true + + // Skip tags comment and Host line + i += 2 + + // Skip until we find the end of this host block (empty line or next Host) + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + + // Skip any trailing empty lines after the host block + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + + continue + } + } + + // Check for Host line without tags + if strings.HasPrefix(line, "Host ") && strings.Fields(line)[1] == hostName { + hostFound = true + + // Skip Host line + i++ + + // Skip until we find the end of this host block + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + + // Skip any trailing empty lines after the host block + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + + continue + } + + // Keep other lines as-is + newLines = append(newLines, lines[i]) + i++ + } + + if !hostFound { + return fmt.Errorf("host '%s' not found", hostName) + } + + // Write back to file + newContent := strings.Join(newLines, "\n") + return os.WriteFile(configPath, []byte(newContent), 0600) +} diff --git a/internal/ui/add_form.go b/internal/ui/add_form.go new file mode 100644 index 0000000..0c7f126 --- /dev/null +++ b/internal/ui/add_form.go @@ -0,0 +1,302 @@ +package ui + +import ( + "os" + "os/user" + "path/filepath" + "sshm/internal/config" + "sshm/internal/validation" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + titleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFDF5")). + Background(lipgloss.Color("#25A065")). + Padding(0, 1) + + fieldStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#04B575")) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")) + + helpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#626262")) +) + +type addFormModel struct { + inputs []textinput.Model + focused int + err string + success bool +} + +const ( + nameInput = iota + hostnameInput + userInput + portInput + identityInput + proxyJumpInput + optionsInput + tagsInput +) + +func RunAddForm(hostname string) error { + // Get current user for default + currentUser, _ := user.Current() + defaultUser := "root" + if currentUser != nil { + defaultUser = currentUser.Username + } + + // Find default identity file + homeDir, _ := os.UserHomeDir() + defaultIdentity := filepath.Join(homeDir, ".ssh", "id_rsa") + + // Check for other common key types + keyTypes := []string{"id_ed25519", "id_ecdsa", "id_rsa"} + for _, keyType := range keyTypes { + keyPath := filepath.Join(homeDir, ".ssh", keyType) + if _, err := os.Stat(keyPath); err == nil { + defaultIdentity = keyPath + break + } + } + + inputs := make([]textinput.Model, 8) + + // Name input + inputs[nameInput] = textinput.New() + inputs[nameInput].Placeholder = "server-name" + inputs[nameInput].Focus() + inputs[nameInput].CharLimit = 50 + inputs[nameInput].Width = 30 + if hostname != "" { + inputs[nameInput].SetValue(hostname) + } + + // Hostname input + inputs[hostnameInput] = textinput.New() + inputs[hostnameInput].Placeholder = "192.168.1.100 or example.com" + inputs[hostnameInput].CharLimit = 100 + inputs[hostnameInput].Width = 30 + + // User input + inputs[userInput] = textinput.New() + inputs[userInput].Placeholder = defaultUser + inputs[userInput].CharLimit = 50 + inputs[userInput].Width = 30 + + // Port input + inputs[portInput] = textinput.New() + inputs[portInput].Placeholder = "22" + inputs[portInput].CharLimit = 5 + inputs[portInput].Width = 30 + + // Identity input + inputs[identityInput] = textinput.New() + inputs[identityInput].Placeholder = defaultIdentity + inputs[identityInput].CharLimit = 200 + inputs[identityInput].Width = 50 + + // ProxyJump input + inputs[proxyJumpInput] = textinput.New() + inputs[proxyJumpInput].Placeholder = "user@jump-host:port or existing-host-name" + inputs[proxyJumpInput].CharLimit = 200 + inputs[proxyJumpInput].Width = 50 + + // SSH Options input + inputs[optionsInput] = textinput.New() + inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60" + inputs[optionsInput].CharLimit = 500 + inputs[optionsInput].Width = 70 + + // Tags input + inputs[tagsInput] = textinput.New() + inputs[tagsInput].Placeholder = "production, web, database" + inputs[tagsInput].CharLimit = 200 + inputs[tagsInput].Width = 50 + + m := addFormModel{ + inputs: inputs, + focused: nameInput, + } + + p := tea.NewProgram(&m, tea.WithAltScreen()) + _, err := p.Run() + return err +} + +func (m *addFormModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m *addFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + + case "ctrl+enter": + // Allow submission from any field with Ctrl+Enter + return m, m.submitForm() + + case "tab", "shift+tab", "enter", "up", "down": + s := msg.String() + + // Handle form submission + if s == "enter" && m.focused == len(m.inputs)-1 { + return m, m.submitForm() + } + + // Cycle inputs + if s == "up" || s == "shift+tab" { + m.focused-- + } else { + m.focused++ + } + + if m.focused > len(m.inputs)-1 { + m.focused = 0 + } else if m.focused < 0 { + m.focused = len(m.inputs) - 1 + } + + for i := range m.inputs { + if i == m.focused { + cmds = append(cmds, m.inputs[i].Focus()) + continue + } + m.inputs[i].Blur() + } + + return m, tea.Batch(cmds...) + } + + case submitResult: + if msg.err != nil { + m.err = msg.err.Error() + } else { + m.success = true + m.err = "" + return m, tea.Quit + } + } + + // Update inputs + cmd := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + m.inputs[i], cmd[i] = m.inputs[i].Update(msg) + } + cmds = append(cmds, cmd...) + + return m, tea.Batch(cmds...) +} + +func (m *addFormModel) View() string { + if m.success { + return "" + } + + var b strings.Builder + + b.WriteString(titleStyle.Render("Add SSH Host Configuration")) + b.WriteString("\n\n") + + fields := []string{ + "Host Name *", + "Hostname/IP *", + "User", + "Port", + "Identity File", + "ProxyJump", + "SSH Options", + "Tags (comma-separated)", + } + + for i, field := range fields { + b.WriteString(fieldStyle.Render(field)) + b.WriteString("\n") + b.WriteString(m.inputs[i].View()) + b.WriteString("\n\n") + } + + if m.err != "" { + b.WriteString(errorStyle.Render("Error: " + m.err)) + b.WriteString("\n\n") + } + + b.WriteString(helpStyle.Render("Tab/Shift+Tab: navigate โ€ข Enter on last field: submit โ€ข Ctrl+Enter: submit โ€ข Ctrl+C/Esc: cancel")) + b.WriteString("\n") + b.WriteString(helpStyle.Render("* Required fields")) + + return b.String() +} + +type submitResult struct { + hostname string + err error +} + +func (m *addFormModel) submitForm() tea.Cmd { + return func() tea.Msg { + // Get values + name := strings.TrimSpace(m.inputs[nameInput].Value()) + hostname := strings.TrimSpace(m.inputs[hostnameInput].Value()) + user := strings.TrimSpace(m.inputs[userInput].Value()) + port := strings.TrimSpace(m.inputs[portInput].Value()) + identity := strings.TrimSpace(m.inputs[identityInput].Value()) + proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value()) + options := strings.TrimSpace(m.inputs[optionsInput].Value()) + + // Set defaults + if user == "" { + user = m.inputs[userInput].Placeholder + } + if port == "" { + port = "22" + } + // Do not auto-fill identity with placeholder if left empty; keep it empty so it's optional + + // Validate all fields + if err := validation.ValidateHost(name, hostname, port, identity); err != nil { + return submitResult{err: err} + } + + tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value()) + var tags []string + if tagsStr != "" { + for _, tag := range strings.Split(tagsStr, ",") { + tag = strings.TrimSpace(tag) + if tag != "" { + tags = append(tags, tag) + } + } + } + + // Create host configuration + host := config.SSHHost{ + Name: name, + Hostname: hostname, + User: user, + Port: port, + Identity: identity, + ProxyJump: proxyJump, + Options: config.ParseSSHOptionsFromCommand(options), + Tags: tags, + } + + // Add to config + err := config.AddSSHHost(host) + return submitResult{hostname: name, err: err} + } +} diff --git a/internal/ui/edit_form.go b/internal/ui/edit_form.go new file mode 100644 index 0000000..a463b13 --- /dev/null +++ b/internal/ui/edit_form.go @@ -0,0 +1,281 @@ +package ui + +import ( + "sshm/internal/config" + "sshm/internal/validation" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + titleStyleEdit = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFDF5")). + Background(lipgloss.Color("#25A065")). + Padding(0, 1) + + fieldStyleEdit = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#04B575")) + + errorStyleEdit = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")) + + helpStyleEdit = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#626262")) +) + +type editFormModel struct { + inputs []textinput.Model + focused int + err string + success bool + originalName string +} + +func RunEditForm(hostName string) error { + // Get the existing host configuration + host, err := config.GetSSHHost(hostName) + if err != nil { + return err + } + + inputs := make([]textinput.Model, 8) + + // Name input + inputs[nameInput] = textinput.New() + inputs[nameInput].Placeholder = "server-name" + inputs[nameInput].Focus() + inputs[nameInput].CharLimit = 50 + inputs[nameInput].Width = 30 + inputs[nameInput].SetValue(host.Name) + + // Hostname input + inputs[hostnameInput] = textinput.New() + inputs[hostnameInput].Placeholder = "192.168.1.100 or example.com" + inputs[hostnameInput].CharLimit = 100 + inputs[hostnameInput].Width = 30 + inputs[hostnameInput].SetValue(host.Hostname) + + // User input + inputs[userInput] = textinput.New() + inputs[userInput].Placeholder = "root" + inputs[userInput].CharLimit = 50 + inputs[userInput].Width = 30 + inputs[userInput].SetValue(host.User) + + // Port input + inputs[portInput] = textinput.New() + inputs[portInput].Placeholder = "22" + inputs[portInput].CharLimit = 5 + inputs[portInput].Width = 30 + inputs[portInput].SetValue(host.Port) + + // Identity input + inputs[identityInput] = textinput.New() + inputs[identityInput].Placeholder = "~/.ssh/id_rsa" + inputs[identityInput].CharLimit = 200 + inputs[identityInput].Width = 50 + inputs[identityInput].SetValue(host.Identity) + + // ProxyJump input + inputs[proxyJumpInput] = textinput.New() + inputs[proxyJumpInput].Placeholder = "user@jump-host:port or existing-host-name" + inputs[proxyJumpInput].CharLimit = 200 + inputs[proxyJumpInput].Width = 50 + inputs[proxyJumpInput].SetValue(host.ProxyJump) + + // SSH Options input + inputs[optionsInput] = textinput.New() + inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60" + inputs[optionsInput].CharLimit = 500 + inputs[optionsInput].Width = 70 + inputs[optionsInput].SetValue(config.FormatSSHOptionsForCommand(host.Options)) + + // Tags input + inputs[tagsInput] = textinput.New() + inputs[tagsInput].Placeholder = "production, web, database" + inputs[tagsInput].CharLimit = 200 + inputs[tagsInput].Width = 50 + if len(host.Tags) > 0 { + inputs[tagsInput].SetValue(strings.Join(host.Tags, ", ")) + } + + m := editFormModel{ + inputs: inputs, + focused: nameInput, + originalName: hostName, + } + + // Open in separate window like add form + p := tea.NewProgram(&m, tea.WithAltScreen()) + _, err = p.Run() + return err +} + +func (m *editFormModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + + case "ctrl+enter": + // Allow submission from any field with Ctrl+Enter + return m, m.submitEditForm() + + case "tab", "shift+tab", "enter", "up", "down": + s := msg.String() + + // Handle form submission + if s == "enter" && m.focused == len(m.inputs)-1 { + return m, m.submitEditForm() + } + + // Cycle inputs + if s == "up" || s == "shift+tab" { + m.focused-- + } else { + m.focused++ + } + + if m.focused > len(m.inputs)-1 { + m.focused = 0 + } else if m.focused < 0 { + m.focused = len(m.inputs) - 1 + } + + for i := range m.inputs { + if i == m.focused { + cmds = append(cmds, m.inputs[i].Focus()) + continue + } + m.inputs[i].Blur() + } + + return m, tea.Batch(cmds...) + } + + case editResult: + if msg.err != nil { + m.err = msg.err.Error() + } else { + m.success = true + m.err = "" + return m, tea.Quit + } + } + + // Update inputs + cmd := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + m.inputs[i], cmd[i] = m.inputs[i].Update(msg) + } + cmds = append(cmds, cmd...) + + return m, tea.Batch(cmds...) +} + +func (m *editFormModel) View() string { + if m.success { + return "" + } + + var b strings.Builder + + b.WriteString(titleStyleEdit.Render("Edit SSH Host Configuration")) + b.WriteString("\n\n") + + fields := []string{ + "Host Name *", + "Hostname/IP *", + "User", + "Port", + "Identity File", + "ProxyJump", + "SSH Options", + "Tags (comma-separated)", + } + + for i, field := range fields { + b.WriteString(fieldStyleEdit.Render(field)) + b.WriteString("\n") + b.WriteString(m.inputs[i].View()) + b.WriteString("\n\n") + } + + if m.err != "" { + b.WriteString(errorStyleEdit.Render("Error: " + m.err)) + b.WriteString("\n\n") + } + + b.WriteString(helpStyleEdit.Render("Tab/Shift+Tab: navigate โ€ข Enter on last field: submit โ€ข Ctrl+Enter: submit โ€ข Ctrl+C/Esc: cancel")) + b.WriteString("\n") + b.WriteString(helpStyleEdit.Render("* Required fields")) + + return b.String() +} + +type editResult struct { + hostname string + err error +} + +func (m *editFormModel) submitEditForm() tea.Cmd { + return func() tea.Msg { + // Get values + name := strings.TrimSpace(m.inputs[nameInput].Value()) + hostname := strings.TrimSpace(m.inputs[hostnameInput].Value()) + user := strings.TrimSpace(m.inputs[userInput].Value()) + port := strings.TrimSpace(m.inputs[portInput].Value()) + identity := strings.TrimSpace(m.inputs[identityInput].Value()) + proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value()) + options := strings.TrimSpace(m.inputs[optionsInput].Value()) + + // Set defaults + if port == "" { + port = "22" + } + // Do not auto-fill identity with placeholder if left empty; keep it empty so it's optional + + // Validate all fields + if err := validation.ValidateHost(name, hostname, port, identity); err != nil { + return editResult{err: err} + } + + // Parse tags + tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value()) + var tags []string + if tagsStr != "" { + for _, tag := range strings.Split(tagsStr, ",") { + tag = strings.TrimSpace(tag) + if tag != "" { + tags = append(tags, tag) + } + } + } + + // Create updated host configuration + host := config.SSHHost{ + Name: name, + Hostname: hostname, + User: user, + Port: port, + Identity: identity, + ProxyJump: proxyJump, + Options: config.ParseSSHOptionsFromCommand(options), + Tags: tags, + } + + // Update the configuration + err := config.UpdateSSHHost(m.originalName, host) + return editResult{hostname: name, err: err} + } +} diff --git a/internal/ui/tui.go b/internal/ui/tui.go new file mode 100644 index 0000000..0b53b61 --- /dev/null +++ b/internal/ui/tui.go @@ -0,0 +1,496 @@ +package ui + +import ( + "fmt" + "os/exec" + "sort" + "strings" + + "sshm/internal/config" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var baseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")) + +var searchStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("36")). + Padding(0, 1) + +type Model struct { + table table.Model + searchInput textinput.Model + hosts []config.SSHHost + filteredHosts []config.SSHHost + searchMode bool + deleteMode bool + deleteHost string + exitAction string + exitHostName string +} + +func (m Model) Init() tea.Cmd { + return textinput.Blink +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // Handle key messages + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc", "ctrl+c": + if m.searchMode { + // Exit search mode + m.searchMode = false + m.searchInput.Blur() + m.table.Focus() + return m, nil + } + if m.deleteMode { + // Exit delete mode + m.deleteMode = false + m.deleteHost = "" + m.table.Focus() + return m, nil + } + return m, tea.Quit + case "q": + if !m.searchMode && !m.deleteMode { + return m, tea.Quit + } + case "/", "ctrl+f": + if !m.searchMode && !m.deleteMode { + // Enter search mode + m.searchMode = true + m.table.Blur() + m.searchInput.Focus() + return m, textinput.Blink + } + case "enter": + if m.searchMode { + // Exit search mode and focus table + m.searchMode = false + m.searchInput.Blur() + m.table.Focus() + return m, nil + } else if m.deleteMode { + // Confirm deletion + err := config.DeleteSSHHost(m.deleteHost) + if err != nil { + // Could show error message here + m.deleteMode = false + m.deleteHost = "" + m.table.Focus() + return m, nil + } + // Refresh the host list + hosts, err := config.ParseSSHConfig() + if err != nil { + // Could show error message here + m.deleteMode = false + m.deleteHost = "" + m.table.Focus() + return m, nil + } + m.hosts = sortHostsByName(hosts) + m.filteredHosts = m.hosts + m.updateTableRows() + m.deleteMode = false + m.deleteHost = "" + m.table.Focus() + return m, nil + } else { + // Connect to selected host + selected := m.table.SelectedRow() + if len(selected) > 0 { + hostName := selected[0] // Host name is in the first column + return m, tea.ExecProcess(exec.Command("ssh", hostName), func(err error) tea.Msg { + return tea.Quit() + }) + } + } + case "e": + if !m.searchMode && !m.deleteMode { + // Edit selected host using dedicated edit form + selected := m.table.SelectedRow() + if len(selected) > 0 { + hostName := selected[0] // Host name is in the first column + // Store the edit action and exit + m.exitAction = "edit" + m.exitHostName = hostName + return m, tea.Quit + } + } + case "a": + if !m.searchMode && !m.deleteMode { + // Add new host using dedicated add form + m.exitAction = "add" + return m, tea.Quit + } + case "d": + if !m.searchMode && !m.deleteMode { + // Delete selected host + selected := m.table.SelectedRow() + if len(selected) > 0 { + hostName := selected[0] // Host name is in the first column + m.deleteMode = true + m.deleteHost = hostName + m.table.Blur() + return m, nil + } + } + } + } + + // Update components based on mode + if m.searchMode { + m.searchInput, cmd = m.searchInput.Update(msg) + // Filter hosts when search input changes + if m.searchInput.Value() != "" { + m.filteredHosts = m.filterHosts(m.searchInput.Value()) + } else { + m.filteredHosts = m.hosts + } + m.updateTableRows() + } else { + m.table, cmd = m.table.Update(msg) + } + + return m, cmd +} + +func (m Model) View() string { + if m.deleteMode { + return m.renderDeleteConfirmation() + } + + var view strings.Builder + + // Add search bar + searchPrompt := "Search (/ to search, ESC to exit search): " + if m.searchMode { + view.WriteString(searchStyle.Render(searchPrompt+m.searchInput.View()) + "\n\n") + } + + // Add table + view.WriteString(baseStyle.Render(m.table.View())) + + // Add help text + if !m.searchMode { + view.WriteString("\nUse โ†‘/โ†“ to navigate โ€ข Enter to connect โ€ข (a)dd โ€ข (e)dit โ€ข (d)elete โ€ข / to search โ€ข (q)uit") + } else { + view.WriteString("\nType to filter hosts by name or tag โ€ข Enter to select โ€ข ESC to exit search") + } + + return view.String() +} + +// sortHostsByName sorts a slice of SSH hosts alphabetically by name +func sortHostsByName(hosts []config.SSHHost) []config.SSHHost { + sorted := make([]config.SSHHost, len(hosts)) + copy(sorted, hosts) + + sort.Slice(sorted, func(i, j int) bool { + return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name) + }) + + return sorted +} + +// calculateNameColumnWidth calculates the optimal width for the Name column +// based on the longest host name, with a minimum of 8 and maximum of 40 characters +func calculateNameColumnWidth(hosts []config.SSHHost) int { + maxLength := 8 // Minimum width to accommodate the "Name" header + + for _, host := range hosts { + if len(host.Name) > maxLength { + maxLength = len(host.Name) + } + } + + // Add some padding (2 characters) for better visual spacing + maxLength += 2 + + // Cap the maximum width to avoid extremely wide columns + if maxLength > 40 { + maxLength = 40 + } + + return maxLength +} + +// calculateTagsColumnWidth calculates the optimal width for the Tags column +// based on the longest tags string, with a minimum of 8 and maximum of 50 characters +func calculateTagsColumnWidth(hosts []config.SSHHost) int { + maxLength := 8 // Minimum width to accommodate the "Tags" header + + for _, host := range hosts { + // Format tags exactly the same way they appear in the table + var tagsStr string + if len(host.Tags) > 0 { + // Add # prefix to each tag and join with spaces + var formattedTags []string + for _, tag := range host.Tags { + formattedTags = append(formattedTags, "#"+tag) + } + tagsStr = strings.Join(formattedTags, " ") + } + + if len(tagsStr) > maxLength { + maxLength = len(tagsStr) + } + } + + // Add some padding (2 characters) for better visual spacing + maxLength += 2 + + // Cap the maximum width to avoid extremely wide columns + if maxLength > 50 { + maxLength = 50 + } + + return maxLength +} + +// NewModel creates a new TUI model with the given SSH hosts +func NewModel(hosts []config.SSHHost) Model { + // Sort hosts alphabetically by name + sortedHosts := sortHostsByName(hosts) + + // Create search input + ti := textinput.New() + ti.Placeholder = "Search hosts or tags..." + ti.CharLimit = 50 + ti.Width = 50 + + // Calculate optimal width for the Name column + nameWidth := calculateNameColumnWidth(sortedHosts) + + // Calculate optimal width for the Tags column + tagsWidth := calculateTagsColumnWidth(sortedHosts) + + // Create table columns + columns := []table.Column{ + {Title: "Name", Width: nameWidth}, + {Title: "Hostname", Width: 25}, + {Title: "User", Width: 12}, + {Title: "Port", Width: 6}, + {Title: "Tags", Width: tagsWidth}, + } + + // Convert hosts to table rows + var rows []table.Row + for _, host := range sortedHosts { + // Format tags for display + var tagsStr string + if len(host.Tags) > 0 { + // Add # prefix to each tag and join with spaces + var formattedTags []string + for _, tag := range host.Tags { + formattedTags = append(formattedTags, "#"+tag) + } + tagsStr = strings.Join(formattedTags, " ") + } + + rows = append(rows, table.Row{ + host.Name, + host.Hostname, + host.User, + host.Port, + tagsStr, + }) + } + + // Dรฉterminer la hauteur du tableau : 1 (header) + nombre de hosts (max 10) + hostCount := len(rows) + tableHeight := 1 // header + if hostCount < 10 { + tableHeight += hostCount + } else { + tableHeight += 10 + } + + // Create table + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + + // Style the table + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + return Model{ + table: t, + searchInput: ti, + hosts: sortedHosts, + filteredHosts: sortedHosts, + searchMode: false, + } +} + +// RunInteractiveMode starts the interactive TUI +func RunInteractiveMode(hosts []config.SSHHost) error { + for { + m := NewModel(hosts) + + // Start the application in terminal (without alt screen) + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + return fmt.Errorf("error running TUI: %w", err) + } + + // Check if the final model indicates an action + if model, ok := finalModel.(Model); ok { + if model.exitAction == "edit" && model.exitHostName != "" { + // Launch the dedicated edit form (opens in separate window) + if err := RunEditForm(model.exitHostName); err != nil { + fmt.Printf("Error editing host: %v\n", err) + // Continue the loop to return to the main interface + continue + } + + // Clear screen before returning to TUI + fmt.Print("\033[2J\033[H") + + // Refresh the hosts list after editing + refreshedHosts, err := config.ParseSSHConfig() + if err != nil { + return fmt.Errorf("error refreshing hosts after edit: %w", err) + } + hosts = refreshedHosts + + // Continue the loop to return to the main interface + continue + } else if model.exitAction == "add" { + // Launch the dedicated add form (opens in separate window) + if err := RunAddForm(""); err != nil { + fmt.Printf("Error adding host: %v\n", err) + // Continue the loop to return to the main interface + continue + } + + // Clear screen before returning to TUI + fmt.Print("\033[2J\033[H") + + // Refresh the hosts list after adding + refreshedHosts, err := config.ParseSSHConfig() + if err != nil { + return fmt.Errorf("error refreshing hosts after add: %w", err) + } + hosts = refreshedHosts + + // Continue the loop to return to the main interface + continue + } + } + + // If no special command, exit normally + break + } + + return nil +} + +// filterHosts filters hosts based on search query (name or tags) +func (m Model) filterHosts(query string) []config.SSHHost { + if query == "" { + return sortHostsByName(m.hosts) + } + + query = strings.ToLower(query) + var filtered []config.SSHHost + + for _, host := range m.hosts { + // Check host name + if strings.Contains(strings.ToLower(host.Name), query) { + filtered = append(filtered, host) + continue + } + + // Check hostname + if strings.Contains(strings.ToLower(host.Hostname), query) { + filtered = append(filtered, host) + continue + } + + // Check tags + for _, tag := range host.Tags { + if strings.Contains(strings.ToLower(tag), query) { + filtered = append(filtered, host) + break + } + } + } + + return sortHostsByName(filtered) +} + +// updateTableRows updates the table with filtered hosts +func (m *Model) updateTableRows() { + var rows []table.Row + hostsToShow := m.filteredHosts + if hostsToShow == nil { + hostsToShow = m.hosts + } + + // Sort hosts alphabetically by name + sortedHosts := sortHostsByName(hostsToShow) + + for _, host := range sortedHosts { + // Format tags for display + var tagsStr string + if len(host.Tags) > 0 { + // Add # prefix to each tag and join with spaces + var formattedTags []string + for _, tag := range host.Tags { + formattedTags = append(formattedTags, "#"+tag) + } + tagsStr = strings.Join(formattedTags, " ") + } + + rows = append(rows, table.Row{ + host.Name, + host.Hostname, + host.User, + host.Port, + tagsStr, + }) + } + + m.table.SetRows(rows) +} + +// enterEditMode initializes edit mode for a specific host +// renderDeleteConfirmation renders the delete confirmation dialog +func (m Model) renderDeleteConfirmation() string { + var view strings.Builder + + view.WriteString(lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("1")). // Red border + Padding(1, 2). + Render(fmt.Sprintf("โš ๏ธ Delete SSH Host\n\nAre you sure you want to delete host '%s'?\n\nThis action cannot be undone.\n\nPress Enter to confirm or Esc to cancel", m.deleteHost))) + + return view.String() +} diff --git a/internal/validation/ssh.go b/internal/validation/ssh.go new file mode 100644 index 0000000..07c24aa --- /dev/null +++ b/internal/validation/ssh.go @@ -0,0 +1,96 @@ +package validation + +import ( + "fmt" + "net" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// ValidateHostname checks if a hostname is valid +func ValidateHostname(hostname string) bool { + if len(hostname) == 0 || len(hostname) > 253 { + return false + } + if strings.HasPrefix(hostname, ".") || strings.HasSuffix(hostname, ".") { + return false + } + if strings.Contains(hostname, " ") { + return false + } + + hostnameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) + return hostnameRegex.MatchString(hostname) +} + +// ValidateIP checks if an IP address is valid +func ValidateIP(ip string) bool { + return net.ParseIP(ip) != nil +} + +// ValidatePort checks if a port is valid +func ValidatePort(port string) bool { + if port == "" { + return true // Empty port defaults to 22 + } + portNum, err := strconv.Atoi(port) + return err == nil && portNum >= 1 && portNum <= 65535 +} + +// ValidateHostName checks if a host name is valid for SSH config +func ValidateHostName(name string) bool { + if len(name) == 0 || len(name) > 50 { + return false + } + // Host name cannot contain whitespace or special SSH config characters + return !strings.ContainsAny(name, " \t\n\r#") +} + +// ValidateIdentityFile checks if an identity file path is valid +func ValidateIdentityFile(path string) bool { + if path == "" { + return true // Optional field + } + // Expand ~ to home directory + if strings.HasPrefix(path, "~/") { + homeDir, err := os.UserHomeDir() + if err != nil { + return false + } + path = filepath.Join(homeDir, path[2:]) + } + _, err := os.Stat(path) + return err == nil +} + +// ValidateHost validates all host fields +func ValidateHost(name, hostname, port, identity string) error { + if strings.TrimSpace(name) == "" { + return fmt.Errorf("host name is required") + } + + if !ValidateHostName(name) { + return fmt.Errorf("invalid host name: cannot contain spaces or special characters") + } + + if strings.TrimSpace(hostname) == "" { + return fmt.Errorf("hostname/IP is required") + } + + if !ValidateHostname(hostname) && !ValidateIP(hostname) { + return fmt.Errorf("invalid hostname or IP address format") + } + + if !ValidatePort(port) { + return fmt.Errorf("port must be between 1 and 65535") + } + + if identity != "" && !ValidateIdentityFile(identity) { + return fmt.Errorf("identity file does not exist: %s", identity) + } + + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3436825 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "sshm/cmd" + +func main() { + cmd.Execute() +}