From 56bdb617894d4175aa0bf7f09d5960e62b7b7e16 Mon Sep 17 00:00:00 2001 From: poneding Date: Mon, 29 Apr 2024 00:02:32 +0800 Subject: [PATCH] first commit --- .github/workflows/release.yaml | 41 ++++++ .gitignore | 26 ++++ .goreleaser.yaml | 43 +++++++ LICENSE | 201 ++++++++++++++++++++++++++++++ README.md | 130 +++++++++++++++++++ README_en-US.md | 130 +++++++++++++++++++ cmd/add.go | 24 ++++ cmd/list _windows.go | 62 +++++++++ cmd/list.go | 67 ++++++++++ cmd/profile.go | 21 ++++ cmd/profile_edit.go | 57 +++++++++ cmd/profile_view.go | 40 ++++++ cmd/remove.go | 60 +++++++++ cmd/root.go | 115 +++++++++++++++++ cmd/upgrade.go | 168 +++++++++++++++++++++++++ cmd/version.go | 26 ++++ go.mod | 38 ++++++ go.sum | 94 ++++++++++++++ internal/ssh/connector.go | 107 ++++++++++++++++ internal/ssh/connector_windows.go | 41 ++++++ internal/ssh/profile.go | 70 +++++++++++ internal/ssh/profile_manager.go | 130 +++++++++++++++++++ internal/ssh/profile_windows.go | 63 ++++++++++ internal/ssh/prompt.go | 188 ++++++++++++++++++++++++++++ internal/ssh/prompt_windows.go | 185 +++++++++++++++++++++++++++ main.go | 10 ++ 26 files changed, 2137 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README_en-US.md create mode 100644 cmd/add.go create mode 100644 cmd/list _windows.go create mode 100644 cmd/list.go create mode 100644 cmd/profile.go create mode 100644 cmd/profile_edit.go create mode 100644 cmd/profile_view.go create mode 100644 cmd/remove.go create mode 100644 cmd/root.go create mode 100644 cmd/upgrade.go create mode 100644 cmd/version.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/ssh/connector.go create mode 100644 internal/ssh/connector_windows.go create mode 100644 internal/ssh/profile.go create mode 100644 internal/ssh/profile_manager.go create mode 100644 internal/ssh/profile_windows.go create mode 100644 internal/ssh/prompt.go create mode 100644 internal/ssh/prompt_windows.go create mode 100644 main.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..093bff3 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,41 @@ +# .github/workflows/release.yml +name: goreleaser + +on: + pull_request: + push: + # run only against tags + tags: + - "*" + +permissions: + contents: write + # packages: write + # issues: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + # More assembly might be required: Docker logins, GPG, etc. + # It all depends on your needs. + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + # either 'goreleaser' (default) or 'goreleaser-pro' + distribution: goreleaser + # 'latest', 'nightly', or a semver + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution + # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5125f6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# 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 + +.vscode/ +.idea/ +test/ +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..e3ebe0a --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,43 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com + +# The lines below are called `modelines`. See `:help modeline` +# Feel free to remove those if you don't want/need to use them. +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 1 + +before: + hooks: + # You may remove this if you don't use go modules. + - go mod tidy + # you may remove this if you don't need go generate + - go generate ./... + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + +archives: + - format: binary + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- .Arch }} + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7db5b31 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +[![Go Report Card](https://goreportcard.com/badge/github.com/poneding/ssher)](https://goreportcard.com/report/github.com/poneding/ssher) +[![GitHub release](https://img.shields.io/github/v/release/poneding/ssher)](https://img.shields.io/github/v/release/poneding/ssher) +[![GitHub license](https://img.shields.io/github/license/poneding/ssher)](https://img.shields.io/github/license/poneding/ssher) +[![GitHub stars](https://img.shields.io/github/stars/poneding/ssher)](https://img.shields.io/github/stars/poneding/ssher) +[![GitHub forks](https://img.shields.io/github/forks/poneding/ssher)](https://img.shields.io/github/forks/poneding/ssher) + +# ssher + +中文 | [ENG](README_en-US.md) + +ssher 是一款轻量的 SSH Profile 管理命令行工具,让你可以更方便的连接到你的服务器。 + +由于是使用 Go 语言开发,所以支持多平台,包括 Linux、MacOS 和 Windows。 + +## 🔍 预览 + +![ssher](https://images.poneding.com/2024/04/202404260925762.gif) + +## ⚙️ 安装 + +### 直接 Go install 安装 + +如果本地已经安装了 Go 环境,可以直接使用 `go install` 安装: + +```bash +go install github.com/poneding/ssher@latest +``` + +### 二进制文件 + +MacOS & Linux 安装,参考以下命令: + +```bash +# MacOS +sudo wget https://ghproxy.ketches.cn/https://github.com/poneding/ssher/releases/download/v0.1.0/ssher_0.1.0_darwin_arm64 -O /user/local/bin/ssher && sudo chmod +x /user/local/bin/ssher + +# Linux +sudo wget https://ghproxy.ketches.cn/https://github.com/poneding/ssher/releases/download/v0.1.0/ssher_0.1.0_linux_amd64 -O /user/local/bin/ssher && sudo chmod +x /user/local/bin/ssher +``` + +> 注意:下载前确认你的系统是 `arm64` 还是 `amd64`,下载对应的二进制文件。 + +Windows 安装,参考以下步骤: + +首先下载 `ssher.exe` 文件: + +```bash +# 下载 .exe 文件 +wget https://ghproxy.ketches.cn/https://github.com/poneding/ssher/releases/download/v0.1.0/ssher_0.1.0_windows_amd64.exe +``` + +下载完成后,将 `ssher.exe` 文件路径添加到环境变量中,或者将其放到一个已经添加到环境变量的路径下。 + +### 浏览器下载 + +[👉🏻 发布下载](https://github.com/poneding/ssher/releases),国内网络访问可能受阻。 + +### 源码编译 + +需要[安装 Go 环境](https://golang.google.cn/doc/install),然后执行以下命令: + +```bash +git clone https://github.com/poneding/ssher.git +cd ssher +go build -o ssher main.go +``` + +## 🛠️ 使用 + +### 快速开始 + +```bash +ssher +``` + +输入以上命令,会进入交互模式,在交互模式中你可以使用通过 `↓ ↑ → ←` 键选择你要连接的 SSH Profile 或者添加新的 SSH Profile。 + +### SSH 连接管理操作 + +```bash +# 查看 ssh 连接列表 +ssher list + +# 添加 ssh 连接 +ssher add + +# 删除 ssh 连接 +ssher remove +``` + +### SSH Profile 文件操作 + +Profile 文件中存储了你的服务器信息,你可以通过以下命令查看和编辑 profile 文件: + +```bash +# 查看 profile +ssher profile view + +# 编辑 profile +ssher profile edit +``` + +### 版本和升级 + +```bash +# 查看版本 +ssher version + +# 升级 +ssher upgrade +``` + +### 命令自动补全 + +```bash +# 将补全脚本写入到 ~/.bashrc 或者 ~/.zshrc 中 +# bash +echo 'source <(ssher completion bash)' >> ~/.bashrc +source ~/.bashrc + +# zsh +echo 'source <(ssher completion zsh)' >> ~/.zshrc +source ~/.zshrc +``` + +## ⭐️ Stars + +[![Stargazers over time](https://starchart.cc/poneding/ssher.svg?variant=adaptive)](https://starchart.cc/poneding/ssher) + +如果您觉得这个项目不错,欢迎给我一个 Star ⭐️,你的支持是我最大的动力。 diff --git a/README_en-US.md b/README_en-US.md new file mode 100644 index 0000000..d75cb03 --- /dev/null +++ b/README_en-US.md @@ -0,0 +1,130 @@ +[![Go Report Card](https://goreportcard.com/badge/github.com/poneding/ssher)](https://goreportcard.com/report/github.com/poneding/ssher) +[![GitHub release](https://img.shields.io/github/v/release/poneding/ssher)](https://img.shields.io/github/v/release/poneding/ssher) +[![GitHub license](https://img.shields.io/github/license/poneding/ssher)](https://img.shields.io/github/license/poneding/ssher) +[![GitHub stars](https://img.shields.io/github/stars/poneding/ssher)](https://img.shields.io/github/stars/poneding/ssher) +[![GitHub forks](https://img.shields.io/github/forks/poneding/ssher)](https://img.shields.io/github/forks/poneding/ssher) + +ENG | [中文](README.md) + +# ssher + +ssher is a lightweight SSH connection management command-line tool that allows you to connect to your server more conveniently. + +ssher developed with Go language, so it supports multiple platforms, including Linux, MacOS, and Windows. + +## 🔍 Preview + +![ssher](https://images.poneding.com/2024/04/202404260925762.gif) + +## ⚙️ Installation + +### Go install + +If you have already installed the Go environment locally, you can directly use `go install` to install: + +```bash +go install github.com/poneding/ssher@latest +``` + +### Wget binary + +MacOS & Linux installation, refer to the following commands: + +```bash +# MacOS +sudo wget https://ghproxy.ketches.cn/https://github.com/poneding/ssher/releases/download/v0.1.0/ssher_0.1.0_darwin_arm64 -O /user/local/bin/ssher && sudo chmod +x /user/local/bin/ssher + +# Linux +sudo wget https://ghproxy.ketches.cn/https://github.com/poneding/ssher/releases/download/v0.1.0/ssher_0.1.0_linux_amd64 -O /user/local/bin/ssher && sudo chmod +x /user/local/bin/ssher +``` + +> Note: Before downloading, make sure your system is `arm64` or `amd64`, and download the corresponding binary file. +\ +Windows installation, refer to the following steps: + +Download the `ssher.exe` file first: + +```bash +# Download .exe file +wget https://github.com/poneding/ssher/releases/download/v0.1.0/ssher_0.1.0_windows_amd64.exe +``` + +Add the `ssher.exe` file path to the environment variable after download done, or put it in a path that has already been added to the environment variable. + +### Download from browser + +[👉🏻 GitHub Releases](https://github.com/poneding/ssher/releases) + +### Compile from source + +[Go environment](https://go.dev/doc/install) is required, then execute the following command: + +```bash +git clone https://github.com/poneding/ssher.git +cd ssher +go build -o ssher main.go +``` + +## 🛠️ Usage + +### Get started + +```bash +ssher +``` + +Execute the above command, you will enter the interactive mode, where you can use the `↓ ↑ → ←` keys to select the ssh profile you want to connect to or add a new ssh profile. + +### SSH profile operation + +```bash +# View the ssh profile list +ssher list + +# Add ssh profile +ssher add + +# Remove ssh profile +ssher remove +``` + +### SSH Profile file operation + +Profile file stores your server information, you can view and edit the profile file with the following commands: + +```bash +# View profile +ssher profile view + +# Edit profile +ssher profile edit +``` + +### Version and upgrade + +```bash +# Check version +ssher version + +# Upgrade +ssher upgrade +``` + +### Auto-completion + +```bash +# Append the completion script to ~/.bashrc or ~/.zshrc +# bash +echo 'source <(ssher completion bash)' >> ~/.bashrc +source ~/.bashrc + +# zsh +echo 'source <(ssher completion zsh)' >> ~/.zshrc +source ~/.zshrc +``` + +## ⭐️ Stars + +[![Stargazers over time](https://starchart.cc/poneding/ssher.svg?variant=adaptive)](https://starchart.cc/poneding/ssher) + +Welcome to give me a Star ⭐️ if this project is helpful to you, your support is my greatest motivation. diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..adef83a --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,24 @@ +/* +Copyright © 2024 Pone Ding +*/ +package cmd + +import ( + "github.com/poneding/ssher/internal/ssh" + "github.com/spf13/cobra" +) + +// addCmd represents the add command +var addCmd = &cobra.Command{ + Use: "add", + Short: "Add a ssh profile.", + Long: `Add a ssh profile, you will be prompted to input the profile name, host, port, user, password and private key file path.`, + Run: func(cmd *cobra.Command, args []string) { + profile := ssh.FormPrompt() + ssh.AddProfile(profile) + }, +} + +func init() { + rootCmd.AddCommand(addCmd) +} diff --git a/cmd/list _windows.go b/cmd/list _windows.go new file mode 100644 index 0000000..7a33b2d --- /dev/null +++ b/cmd/list _windows.go @@ -0,0 +1,62 @@ +//go:build windows +// +build windows + +/* +Copyright © 2024 Pone Ding +*/ +package cmd + +import ( + "fmt" + "os" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/poneding/ssher/internal/ssh" + "github.com/spf13/cobra" +) + +// listCmd represents the list command +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all ssh profiles.", + Long: `List all ssh profiles.`, + Aliases: []string{"ls"}, + Run: func(cmd *cobra.Command, args []string) { + runList() + }, +} + +func init() { + rootCmd.AddCommand(listCmd) +} + +func runList() { + profiles := ssh.GetProfiles() + if len(profiles) == 0 { + fmt.Println("No profiles found, add a new ssh profile with `ssher add`.") + return + } + + var paddingLen int + for _, p := range profiles { + if len(p.Name) > paddingLen { + paddingLen = len(p.Name) + } + } + + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"", "Name", "Host", "Port", "User", "Private Key"}) + + current := func(p *ssh.Profile) string { + if p.Current { + return "*" + } + return " " + } + + for _, p := range profiles { + t.AppendRow(table.Row{current(p), p.Name, p.Host, p.Port, p.User, p.PrivateKey}) + } + t.Render() +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..4870d42 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,67 @@ +//go:build !windows +// +build !windows + +/* +Copyright © 2024 Pone Ding +*/ +package cmd + +import ( + "fmt" + "os" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/poneding/ssher/internal/ssh" + "github.com/spf13/cobra" +) + +// listCmd represents the list command +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all ssh profiles.", + Long: `List all ssh profiles.`, + Aliases: []string{"ls"}, + Run: func(cmd *cobra.Command, args []string) { + runList() + }, +} + +func init() { + rootCmd.AddCommand(listCmd) +} + +func runList() { + profiles := ssh.GetProfiles() + if len(profiles) == 0 { + fmt.Println("No profiles found, add a new ssh profile with `ssher add`.") + return + } + + var paddingLen int + for _, p := range profiles { + if len(p.Name) > paddingLen { + paddingLen = len(p.Name) + } + } + + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"", "Name", "Host", "Port", "User", "Password", "Private Key"}) + + current := func(p *ssh.Profile) string { + if p.Current { + return "*" + } + return " " + } + password := func(p *ssh.Profile) string { + if p.Password != "" { + return "******" + } + return "" + } + for _, p := range profiles { + t.AppendRow(table.Row{current(p), p.Name, p.Host, p.Port, p.User, password(p), p.PrivateKey}) + } + t.Render() +} diff --git a/cmd/profile.go b/cmd/profile.go new file mode 100644 index 0000000..3880f4c --- /dev/null +++ b/cmd/profile.go @@ -0,0 +1,21 @@ +/* +Copyright © 2024 Pone Ding +*/ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// profileCmd represents the profile file operations command +var profileCmd = &cobra.Command{ + Use: "profile", + Short: "ssh profile file operations.", + Long: `ssh profile file operations.`, + // Run: func(cmd *cobra.Command, args []string) { + // }, +} + +func init() { + rootCmd.AddCommand(profileCmd) +} diff --git a/cmd/profile_edit.go b/cmd/profile_edit.go new file mode 100644 index 0000000..dabf51e --- /dev/null +++ b/cmd/profile_edit.go @@ -0,0 +1,57 @@ +/* +Copyright © 2024 Pone Ding +*/ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "runtime" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// profileEditCmd represents the edit command +var profileEditCmd = &cobra.Command{ + Use: "edit", + Short: "Edit the ssh profile file.", + Long: `Edit the ssh profile file. The default editor is vim or vi on Unix-like systems, and notepad on Windows.`, + Run: func(cmd *cobra.Command, args []string) { + runProfileEdit() + }, +} + +func init() { + profileCmd.AddCommand(profileEditCmd) +} + +func runProfileEdit() { + file := viper.ConfigFileUsed() + if file == "" { + fmt.Println("✗ No profile file found.") + os.Exit(0) + } + switch runtime.GOOS { + case "windows": + // use notepad to edit + if err := exec.Command("notepad", file).Run(); err != nil { + fmt.Printf("✗ Failed to open %s with notepad: %v\n", file, err) + } + default: + // use vi or vim to edit + textEditor := "vim" // default to vim + if _, err := exec.LookPath("vim"); err != nil { + textEditor = "vi" + } + cmd := exec.Command(textEditor, file) + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + if err := cmd.Run(); err != nil { + fmt.Printf("✗ Failed to open %s with vi: %v\n", file, err) + } + } + os.Exit(0) +} diff --git a/cmd/profile_view.go b/cmd/profile_view.go new file mode 100644 index 0000000..94c5f11 --- /dev/null +++ b/cmd/profile_view.go @@ -0,0 +1,40 @@ +/* +Copyright © 2024 Pone Ding +*/ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// profileViewCmd represents the view command +var profileViewCmd = &cobra.Command{ + Use: "view", + Short: "View the ssh profile file.", + Long: `View the ssh profile file.`, + Run: func(cmd *cobra.Command, args []string) { + runProfileView() + }, +} + +func init() { + profileCmd.AddCommand(profileViewCmd) +} + +func runProfileView() { + file := viper.ConfigFileUsed() + if file == "" { + fmt.Println("✗ No profile file found.") + os.Exit(0) + } + b, err := os.ReadFile(file) + if err != nil { + fmt.Printf("✗ Failed to read %s: %v\n", file, err) + os.Exit(0) + } + fmt.Println(string(b)) +} diff --git a/cmd/remove.go b/cmd/remove.go new file mode 100644 index 0000000..5717034 --- /dev/null +++ b/cmd/remove.go @@ -0,0 +1,60 @@ +/* +Copyright © 2024 Pone Ding +*/ +package cmd + +import ( + "fmt" + "os" + + "github.com/poneding/ssher/internal/ssh" + "github.com/spf13/cobra" +) + +// removeCmd represents the remove command +var removeCmd = &cobra.Command{ + Use: "remove", + Short: "Remove a ssh profile.", + Long: "Remove a ssh profile, you will be prompted to select a profile to remove or just input the profile name after `--name` or `-n", + Aliases: []string{"rm"}, + Run: func(cmd *cobra.Command, args []string) { + runRemove() + }, +} + +var nameSSHProfileRemoved string + +func init() { + rootCmd.AddCommand(removeCmd) + + removeCmd.Flags().StringVarP(&nameSSHProfileRemoved, "name", "n", "", "ssh profile to remove (ssh name)") + removeCmd.RegisterFlagCompletionFunc("name", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + profiles := ssh.GetProfiles() + var completions []string + for _, p := range profiles { + if p.Name == toComplete || p.Host == toComplete { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + if p.Name != "" { + completions = append(completions, p.Name) + } + } + return completions, cobra.ShellCompDirectiveNoFileComp + }) +} + +func runRemove() { + var profile *ssh.Profile + if nameSSHProfileRemoved != "" { + fmt.Printf("✓ SSH profile to remove: %s\n", nameSSHProfileRemoved) + profile = ssh.GetProfile(nameSSHProfileRemoved) + if profile == nil { + fmt.Printf("✗ Profile %s not found\n", nameSSHProfileRemoved) + os.Exit(0) + } + } else { + profile = ssh.SelectPrompt(ssh.RemovePromptLable) + } + + ssh.RemoveProfile(profile) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..8185de2 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,115 @@ +/* +Copyright © 2024 Pone Ding +*/ +package cmd + +import ( + "errors" + "fmt" + "os" + + "github.com/poneding/ssher/internal/ssh" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var cfgFile string + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "ssher", + Short: "ssher is a lightweight ssh profile cli manager.", + Long: `ssher is a lightweight ssh profile cli manager.`, + Run: func(cmd *cobra.Command, args []string) { + runConnect() + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + err := rootCmd.Execute() + if err != nil { + fmt.Println("✗ Error:", err) + os.Exit(0) + } +} + +var nameSSHProfileConnect string + +func init() { + cobra.OnInitialize(initConfig) + + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.ssher.yaml)") + + rootCmd.Flags().StringVarP(&nameSSHProfileConnect, "name", "n", "", "ssh profile to connect. (ssh name)") + rootCmd.RegisterFlagCompletionFunc("name", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + profiles := ssh.GetProfiles() + var completions []string + for _, p := range profiles { + if p.Name == toComplete { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + if p.Name != "" { + completions = append(completions, p.Name) + } + } + return completions, cobra.ShellCompDirectiveNoFileComp + }) +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + if cfgFile != "" { + // Use config file from the flag. + viper.SetConfigFile(cfgFile) + } else { + // Search config in home directory with name ".ssher" (without extension). + viper.AddConfigPath(userHomeDirOrDie()) + viper.SetConfigType("yaml") + viper.SetConfigName(".ssher") + } + + viper.AutomaticEnv() // read in environment variables that match + + if err := viper.ReadInConfig(); errors.As(err, &viper.ConfigFileNotFoundError{}) { + file := viper.ConfigFileUsed() + if file == "" { + + file = userHomeDirOrDie() + "/.ssher.yaml" + } + ssh.CreateProfileFile(file) + } +} + +func userHomeDirOrDie() string { + path, err := os.UserHomeDir() + if err != nil { + fmt.Println("✗ Error:", err) + os.Exit(0) + } + return path +} + +func runConnect() { + var profile *ssh.Profile + + if nameSSHProfileConnect != "" { + fmt.Printf("✓ SSH profile to connect: %s\n", nameSSHProfileConnect) + profile = ssh.GetProfile(nameSSHProfileConnect) + if profile == nil { + fmt.Println("✗ No such profile found.") + os.Exit(0) + } + } else { + profile = ssh.SelectPrompt(ssh.ConnectPromptLable) + } + + // reset current profile + ssh.GetCurrentProfile().Current = false + profile.Current = true + ssh.SaveProfiles() + + // ssh connect + ssh.Connect(profile) +} diff --git a/cmd/upgrade.go b/cmd/upgrade.go new file mode 100644 index 0000000..f819904 --- /dev/null +++ b/cmd/upgrade.go @@ -0,0 +1,168 @@ +/* +Copyright © 2024 Pone Ding +*/ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/spf13/cobra" +) + +const ( + GH_REPO = "github.com/poneding/ssher" + GH_PROXY = "https://ghproxy.ketches.cn/" + GH_ADDR = "https://" + GH_REPO + GH_RELEASE_ADDR_BASE = GH_ADDR + "/releases" + GH_RELEASE_PROXY_ADDR_BASE = GH_PROXY + GH_RELEASE_ADDR_BASE + GH_API_PROXY_ADDR_BASE = GH_PROXY + "https://api.github.com/repos/poneding/ssher" +) + +// upgradeCmd represents the upgrade command +var upgradeCmd = &cobra.Command{ + Use: "upgrade", + Short: "Upgrade ssher to the latest version or specific version.", + Long: `Upgrade ssher to the latest version or specific version.`, + Run: func(cmd *cobra.Command, args []string) { + runUpgrade() + }, +} + +var targetVersion string + +func init() { + rootCmd.AddCommand(upgradeCmd) + + upgradeCmd.Flags().StringVarP(&targetVersion, "version", "v", "", "upgrade to specific version") +} + +type getReleaseResult struct { + TagName string `json:"tag_name"` +} + +func runUpgrade() { + if targetVersion == "" { + targetVersion = "latest" + } + + if targetVersion == version { + fmt.Printf("✓ Current version: %s, upgradtion ignored.\n", version) + } + + // remote version check + suffix := func() string { + if targetVersion == "latest" { + return targetVersion + } else { + return "tags/" + targetVersion + } + }() + r, err := http.Get(fmt.Sprintf("%s/releases/%s", GH_API_PROXY_ADDR_BASE, suffix)) + if err != nil { + fmt.Println("✗ Failed to get version:", err) + os.Exit(0) + } + if r.StatusCode != http.StatusOK { + fmt.Println("✗ Failed to get version:", r.Status) + os.Exit(0) + } + defer r.Body.Close() + + result := getReleaseResult{} + if err := json.NewDecoder(r.Body).Decode(&result); err != nil { + fmt.Println("✗ Failed to get version:", err) + os.Exit(0) + } + if result.TagName == "" { + fmt.Println("✗ Failed to get version: empty") + os.Exit(0) + } + + targetVersion = result.TagName + fmt.Printf("✓ Version %s is available, upgrading now...\n", targetVersion) + + upgrade() +} + +// upgrade ssher to target version, go install is recommended +func upgrade() { + _, err := exec.LookPath("go") + if err == nil { + fmt.Println("✓ Upgrading by go install...") + upgradeByGoInstall(targetVersion) + } else { + fmt.Println("✓ Downloading ssher from github release...") + upgradeByDownload(targetVersion) + } + fmt.Println("✓ Upgraded to version:", targetVersion) +} + +// upgradeByGoInstall upgrade by go install command +func upgradeByGoInstall(v string) { + if targetVersion == "" { + targetVersion = "latest" + } + + cmd := exec.Command("go", "install", GH_REPO+"@"+v) + err := cmd.Run() + if err != nil { + fmt.Println("✗ Upgrade failed by go install:", err) + os.Exit(0) + } +} + +// upgradeByDownload download binary from github release and replace the current binary +func upgradeByDownload(v string) { + if targetVersion == "" { + targetVersion = "latest" + } + + var ext string + if runtime.GOOS == "windows" { + ext = ".exe" + } + + targetFile := fmt.Sprintf("ssher_%s_%s_%s%s", strings.Trim(v, "v"), runtime.GOOS, runtime.GOARCH, ext) + + // download from github release. eg: https://github.com/poneding/ssher/releases/download/v0.1.0/ssher_0.1.0_linux_amd64 + r, err := http.Get(fmt.Sprintf("%s/download/%s/%s", GH_RELEASE_PROXY_ADDR_BASE, v, targetFile)) + if err != nil || r.StatusCode != http.StatusOK { + fmt.Println("✗ Failed to download:", err) + os.Exit(0) + } + defer r.Body.Close() + + // get ssher path + ssherPath, err := os.Executable() + if err != nil { + fmt.Println("✗ Failed to get ssher path:", err) + os.Exit(0) + } + + binaryData, err := io.ReadAll(r.Body) + if err != nil { + fmt.Println("✗ Failed to read all:", err) + os.Exit(0) + } + + // write binaryData to targetFile and set permission + err = os.WriteFile(targetFile, binaryData, 0755) + if err != nil { + fmt.Println("✗ Failed to write file:", err) + os.Exit(0) + } + + // mv targetFile to ssherPath + err = os.Rename(targetFile, ssherPath) + if err != nil { + fmt.Println("✗ Failed to rename:", err) + os.Exit(0) + } +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..46a11be --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,26 @@ +/* +Copyright © 2024 Pone Ding +*/ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +const version = "v1.0.0" + +// versionCmd represents the version command +var versionCmd = &cobra.Command{ + Use: "version", + Short: "ssher version.", + Long: `ssher version`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("✓ Version: %s\n", version) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4aaf011 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module github.com/poneding/ssher + +go 1.22.1 + +require ( + github.com/creack/pty v1.1.21 + github.com/jedib0t/go-pretty/v6 v6.5.8 + github.com/manifoldco/promptui v0.9.0 + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.18.2 + golang.org/x/term v0.19.0 +) + +require ( + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a9626d0 --- /dev/null +++ b/go.sum @@ -0,0 +1,94 @@ +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty/v6 v6.5.8 h1:8BCzJdSvUbaDuRba4YVh+SKMGcAAKdkcF3SVFbrHAtQ= +github.com/jedib0t/go-pretty/v6 v6.5.8/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/ssh/connector.go b/internal/ssh/connector.go new file mode 100644 index 0000000..e1f9cce --- /dev/null +++ b/internal/ssh/connector.go @@ -0,0 +1,107 @@ +//go:build !windows +// +build !windows + +/* +Copyright © 2024 Pone Ding +*/ + +package ssh + +import ( + "bytes" + "fmt" + "io" + "log" + "os" + "os/exec" + "os/signal" + "runtime" + "strconv" + "syscall" + + "github.com/creack/pty" + "golang.org/x/term" +) + +// Connect to the ssh server. +func Connect(profile *Profile) { + cmd := exec.Command("ssh", "-p", strconv.Itoa(profile.Port)) + passwordSSH := profile.Password != "" + if profile.PrivateKey != "" { + if profile.PrivateKey != "" { + cmd.Args = append(cmd.Args, "-i", profile.PrivateKey) + } + } + + if profile.User != "" { + cmd.Args = append(cmd.Args, profile.User+"@"+profile.Host) + } else { + cmd.Args = append(cmd.Args, profile.Host) + } + + ptmx, err := pty.Start(cmd) + if err != nil { + fmt.Println("✗ Failed to start ssh:", err) + os.Exit(0) + } + // Make sure to close the pty at the end. + defer func() { _ = ptmx.Close() }() // Best effort. + + // Handle pty size. + if runtime.GOOS != "windows" { + // + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGWINCH) + go func() { + for range ch { + if err := pty.InheritSize(os.Stdin, ptmx); err != nil { + log.Printf("error resizing pty: %s", err) + } + } + }() + ch <- syscall.SIGWINCH // Initial resize. + defer func() { signal.Stop(ch); close(ch) }() // Cleanup signals when done. + } + + // Set stdin in raw mode. + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + fmt.Println("✗ Failed to set stdin in raw mode:", err) + os.Exit(0) + } + defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() // Best effort. + + // Copy stdin to the pty and the pty to stdout. + // NOTE: The goroutine will keep reading until the next keystroke before returning. + go func() { _, _ = io.Copy(ptmx, os.Stdin) }() + + for i := 0; passwordSSH && i < 3; i++ { + // Read the pty until wait for password. + buf := make([]byte, 1024*4) + n, err := ptmx.Read(buf) + if err != nil { + fmt.Println("✗ Failed to read pty:", err) + os.Exit(0) + } + if bytes.HasPrefix(buf[:n], []byte("The authenticity of host ")) { + // Write yes to the pty. + _, err = ptmx.Write([]byte("yes\n")) + if err != nil { + fmt.Println("✗ Failed to write yes to pty:", err) + os.Exit(0) + } + continue + } + if bytes.HasSuffix(buf[:n], []byte("password: ")) { + // Write the password to the pty. + _, err = ptmx.Write([]byte(profile.Password + "\n")) + if err != nil { + fmt.Println("✗ Failed to write password to pty:", err) + os.Exit(0) + } + } + break + } + + _, _ = io.Copy(os.Stdout, ptmx) +} diff --git a/internal/ssh/connector_windows.go b/internal/ssh/connector_windows.go new file mode 100644 index 0000000..570e7fc --- /dev/null +++ b/internal/ssh/connector_windows.go @@ -0,0 +1,41 @@ +//go:build windows +// +build windows + +/* +Copyright © 2024 Pone Ding +*/ + +package ssh + +import ( + "fmt" + "os" + "os/exec" + "strconv" +) + +// Connect to the ssh server. +func Connect(profile *Profile) { + cmd := exec.Command("ssh", "-p", strconv.Itoa(profile.Port)) + if profile.PrivateKey != "" { + if profile.PrivateKey != "" { + cmd.Args = append(cmd.Args, "-i", profile.PrivateKey) + } + } + + if profile.User != "" { + cmd.Args = append(cmd.Args, profile.User+"@"+profile.Host) + } else { + cmd.Args = append(cmd.Args, profile.Host) + } + + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + fmt.Println("✗ Failed to run ssh:", err) + os.Exit(0) + } + cmd.Wait() +} diff --git a/internal/ssh/profile.go b/internal/ssh/profile.go new file mode 100644 index 0000000..7c1d0c2 --- /dev/null +++ b/internal/ssh/profile.go @@ -0,0 +1,70 @@ +//go:build !windows +// +build !windows + +/* +Copyright © 2024 Pone Ding +*/ +package ssh + +import ( + "fmt" + "os" +) + +type Profile struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + PrivateKey string `json:"privateKey"` + Current bool `json:"current"` +} + +type Options func(*Profile) + +func NewProfile(name, host string, options ...Options) *Profile { + p := &Profile{ + Name: name, + Host: host, + } + + for _, option := range options { + option(p) + } + + if p.Port == 0 { + p.Port = 22 + } + + if p.Host == "" { + fmt.Println("✗ Host is required for a profile") + os.Exit(0) + } + + return p +} + +func WithPort(port int) Options { + return func(p *Profile) { + p.Port = port + } +} + +func WithUser(user string) Options { + return func(p *Profile) { + p.User = user + } +} + +func WithPassword(password string) Options { + return func(p *Profile) { + p.Password = password + } +} + +func WithPrivateKey(privateKey string) Options { + return func(p *Profile) { + p.PrivateKey = privateKey + } +} diff --git a/internal/ssh/profile_manager.go b/internal/ssh/profile_manager.go new file mode 100644 index 0000000..568001b --- /dev/null +++ b/internal/ssh/profile_manager.go @@ -0,0 +1,130 @@ +/* +Copyright © 2024 Pone Ding +*/ +package ssh + +import ( + "fmt" + "os" + + "github.com/spf13/viper" +) + +type config struct { + Profiles []*Profile `json:"profiles"` +} + +var c *config + +// readProfiles reads the profiles from the config file +func readProfiles() { + if c == nil { + c = new(config) + err := viper.Unmarshal(c) + if err != nil { + fmt.Printf("✗ Unable to decode into struct %v", err) + os.Exit(0) + } + } +} + +// AddProfile adds a new ssh profile +func AddProfile(profile *Profile) { + defer func() { + SaveProfiles() + fmt.Printf("✔ A ssh profile added: %s\t%s\n", profile.Name, profile.Host) + }() + + readProfiles() + + if profile == nil { + fmt.Println("✗ ssh profile is required") + os.Exit(0) + } + + if profile.Name == "" { + fmt.Println("✗ ssh profile name is required for a profile") + os.Exit(0) + } + + if profile.Host == "" { + fmt.Println("✗ ssh profile host is required for a profile") + os.Exit(0) + } + + if profile.Port == 0 { + profile.Port = 22 + } + + if len(c.Profiles) == 0 { + profile.Current = true + } + c.Profiles = append(c.Profiles, profile) +} + +// GetProfiles returns all the profiles +func GetProfiles() []*Profile { + readProfiles() + return c.Profiles +} + +// GetProfile returns a profile by name +func GetProfile(name string) *Profile { + readProfiles() + for i := range c.Profiles { + if c.Profiles[i].Name == name { + return c.Profiles[i] + } + } + return nil +} + +// GetCurrentProfile returns the current profile +func GetCurrentProfile() *Profile { + readProfiles() + for i := range c.Profiles { + if c.Profiles[i].Current { + return c.Profiles[i] + } + } + return nil +} + +// RemoveProfile removes a profile +func RemoveProfile(profile *Profile) { + defer func() { + SaveProfiles() + fmt.Printf("✔ A ssh profile removed: %s\t%s\n", profile.Name, profile.Host) + }() + + readProfiles() + + for i := range c.Profiles { + if c.Profiles[i].Name == profile.Name { + c.Profiles = append(c.Profiles[:i], c.Profiles[i+1:]...) + return + } + } +} + +// CreateProfileFile creates ssh profile file +func CreateProfileFile(file string) { + f, err := os.Create(file) + if err != nil { + fmt.Printf("✗ Unable to create profile file, %v", err) + os.Exit(0) + } + defer f.Close() +} + +// SaveProfiles save the profiles to the profile file +func SaveProfiles() { + readProfiles() + viper.Set("profiles", c.Profiles) + + err := viper.WriteConfig() + if err != nil { + fmt.Printf("✗ Unable to write to profile file, %v", err) + os.Exit(0) + } +} diff --git a/internal/ssh/profile_windows.go b/internal/ssh/profile_windows.go new file mode 100644 index 0000000..9407a49 --- /dev/null +++ b/internal/ssh/profile_windows.go @@ -0,0 +1,63 @@ +//go:build windows +// +build windows + +/* +Copyright © 2024 Pone Ding +*/ +package ssh + +import ( + "fmt" + "os" +) + +type Profile struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + PrivateKey string `json:"privateKey"` + Current bool `json:"current"` +} + +type Options func(*Profile) + +func NewProfile(name, host string, options ...Options) *Profile { + p := &Profile{ + Name: name, + Host: host, + } + + for _, option := range options { + option(p) + } + + if p.Port == 0 { + p.Port = 22 + } + + if p.Host == "" { + fmt.Println("✗ Host is required for a profile") + os.Exit(0) + } + + return p +} + +func WithPort(port int) Options { + return func(p *Profile) { + p.Port = port + } +} + +func WithUser(user string) Options { + return func(p *Profile) { + p.User = user + } +} + +func WithPrivateKey(privateKey string) Options { + return func(p *Profile) { + p.PrivateKey = privateKey + } +} diff --git a/internal/ssh/prompt.go b/internal/ssh/prompt.go new file mode 100644 index 0000000..608bc56 --- /dev/null +++ b/internal/ssh/prompt.go @@ -0,0 +1,188 @@ +//go:build !windows +// +build !windows + +/* +Copyright © 2024 Pone Ding +*/ +package ssh + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/manifoldco/promptui" +) + +type PromptLable string + +const ( + ConnectPromptLable PromptLable = "Select a ssh profile to connect: " + RemovePromptLable PromptLable = "Select a ssh profile to remove: " +) + +type PromptOperation string + +const ( + AddSSHProfilePromptOperation PromptOperation = "+ Add a new ssh profile" + ExitPromptOperation PromptOperation = "✗ Exit" +) + +// SelectPrompt prompts the user to select a ssh profile, and returns the selected profile. +func SelectPrompt(label PromptLable) (selected *Profile) { + profiles := GetProfiles() + cursorPos := 0 + for i, p := range profiles { + if p.Current { + cursorPos = i + } + } + + prompt := promptui.Select{ + // Label: label, + Items: append(profiles, &Profile{Name: string(AddSSHProfilePromptOperation)}, &Profile{Name: string(ExitPromptOperation)}), + Searcher: func(input string, index int) bool { + if index < 0 || index >= len(profiles) { + return false + } + + current := profiles[index] + if strings.Contains(strings.ToLower(current.Name), strings.ToLower(input)) || + strings.Contains(strings.ToLower(current.Host), strings.ToLower(input)) { + return true + } + + return false + }, + CursorPos: cursorPos, + Templates: &promptui.SelectTemplates{ + Label: string(label), + Active: promptui.Styler(promptui.FGRed, promptui.FGBold)("➜ {{if .Current}}{{ \"* \" }}{{end}}{{ .Name }}\t{{ .Host }}"), + Inactive: promptui.Styler(promptui.FGCyan)(" {{if .Current}}{{ \"* \" }}{{end}}{{ .Name }}\t{{ .Host }}"), + Selected: promptui.Styler(promptui.FGCyan)("✔ Seletcd: {{ .Name }}\t{{ .Host }}"), + Details: ` +---------- Profile ---------- +{{ "Name:" | faint }} {{ .Name }} +{{ "Host:" | faint }} {{ .Host }} +{{ "Port:" | faint }} {{ .Port }} +{{ "User:" | faint }} {{ .User }} +{{ "PrivateKey:" | faint }} {{ .PrivateKey }}`, + }, + } + + index, _, err := prompt.Run() + if err != nil { + fmt.Println("✗ Prompt failed:", err) + os.Exit(0) + } + + // AddSSHProfilePromptOperation selected + if index == len(profiles) { + profile := FormPrompt() + AddProfile(profile) + os.Exit(0) + } + + // ExitPromptOperation selected + if index == len(profiles)+1 { + os.Exit(0) + } + + if label == RemovePromptLable { + prompt := promptui.Prompt{ + Label: promptui.Styler(promptui.FGRed)("⚠ Are you sure to remove this profile? [y/n]"), + Default: "n", + } + c, err := prompt.Run() + if err != nil { + fmt.Println("✗ Prompt failed:", err) + os.Exit(0) + } + if strings.ToLower(c) != "y" { + os.Exit(0) + } + } + + selected = profiles[index] + return +} + +// FormPrompt prompts the user to fill in the ssh profile fields, and returns the new profile. +func FormPrompt() *Profile { + profiles := GetProfiles() + var newProfile = new(Profile) + + fmt.Println("Add a new ssh profile, fill in the following fields (q to quit):") + + var promptLabels = []string{"Name(*)", "Host(*)", "Port", "User", "Password", "PrivateKey"} + m := make(map[string]string, len(promptLabels)) + + for _, p := range promptLabels { + prompt := promptui.Prompt{ + Label: p, + } + switch p { + case "Name": + prompt.Validate = func(input string) error { + input = strings.Trim(input, " ") + if input == "" { + return fmt.Errorf("ssh profile %s is required", p) + } + + for _, p := range profiles { + if p.Name == input { + return fmt.Errorf("ssh profile name %s already exists", input) + } + } + return nil + } + case "Host": + prompt.Validate = func(input string) error { + input = strings.Trim(input, " ") + if input == "" { + return fmt.Errorf("ssh profile %s is required", p) + } + return nil + } + case "Password": + prompt.Mask = '*' + case "Port": + prompt.Default = " 22" + prompt.Validate = func(input string) error { + input = strings.Trim(input, " ") + if input == "q" { + os.Exit(0) + } + _, err := strconv.Atoi(input) + return err + } + case "PrivateKey": + prompt.Validate = func(input string) error { + input = strings.Trim(input, " ") + if input == "" { + return nil + } + if input == "q" { + os.Exit(0) + } + _, err := os.Stat(input) + return err + } + } + value, _ := prompt.Run() + if value == "q" { + os.Exit(0) + } + m[p] = strings.Trim(value, " ") + } + + newProfile.Name = m["Name(*)"] + newProfile.Host = m["Host(*)"] + newProfile.Port, _ = strconv.Atoi(m["Port"]) + newProfile.User = m["User"] + newProfile.Password = m["Password"] + newProfile.PrivateKey = m["PrivateKey"] + + return newProfile +} diff --git a/internal/ssh/prompt_windows.go b/internal/ssh/prompt_windows.go new file mode 100644 index 0000000..2f03277 --- /dev/null +++ b/internal/ssh/prompt_windows.go @@ -0,0 +1,185 @@ +//go:build windows +// +build windows + +/* +Copyright © 2024 Pone Ding +*/ +package ssh + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/manifoldco/promptui" +) + +type PromptLable string + +const ( + ConnectPromptLable PromptLable = "Select a ssh profile to connect: " + RemovePromptLable PromptLable = "Select a ssh profile to remove: " +) + +type PromptOperation string + +const ( + AddSSHProfilePromptOperation PromptOperation = "+ Add a new ssh profile" + ExitPromptOperation PromptOperation = "✗ Exit" +) + +// SelectPrompt prompts the user to select a ssh profile, and returns the selected profile. +func SelectPrompt(label PromptLable) (selected *Profile) { + profiles := GetProfiles() + cursorPos := 0 + for i, p := range profiles { + if p.Current { + cursorPos = i + } + } + + prompt := promptui.Select{ + // Label: label, + Items: append(profiles, &Profile{Name: string(AddSSHProfilePromptOperation)}, &Profile{Name: string(ExitPromptOperation)}), + Searcher: func(input string, index int) bool { + if index < 0 || index >= len(profiles) { + return false + } + + current := profiles[index] + if strings.Contains(strings.ToLower(current.Name), strings.ToLower(input)) || + strings.Contains(strings.ToLower(current.Host), strings.ToLower(input)) { + return true + } + + return false + }, + CursorPos: cursorPos, + Templates: &promptui.SelectTemplates{ + Label: string(label), + Active: promptui.Styler(promptui.FGRed, promptui.FGBold)("➜ {{if .Current}}{{ \"* \" }}{{end}}{{ .Name }}\t{{ .Host }}"), + Inactive: promptui.Styler(promptui.FGCyan)(" {{if .Current}}{{ \"* \" }}{{end}}{{ .Name }}\t{{ .Host }}"), + Selected: promptui.Styler(promptui.FGCyan)("✔ Seletcd: {{ .Name }}\t{{ .Host }}"), + Details: ` +---------- Profile ---------- +{{ "Name:" | faint }} {{ .Name }} +{{ "Host:" | faint }} {{ .Host }} +{{ "Port:" | faint }} {{ .Port }} +{{ "User:" | faint }} {{ .User }} +{{ "PrivateKey:" | faint }} {{ .PrivateKey }}`, + }, + } + + index, _, err := prompt.Run() + if err != nil { + fmt.Println("✗ Prompt failed:", err) + os.Exit(0) + } + + // AddSSHProfilePromptOperation selected + if index == len(profiles) { + profile := FormPrompt() + AddProfile(profile) + os.Exit(0) + } + + // ExitPromptOperation selected + if index == len(profiles)+1 { + os.Exit(0) + } + + if label == RemovePromptLable { + prompt := promptui.Prompt{ + Label: promptui.Styler(promptui.FGRed)("⚠ Are you sure to remove this profile? [y/n]"), + Default: "n", + } + c, err := prompt.Run() + if err != nil { + fmt.Println("✗ Prompt failed:", err) + os.Exit(0) + } + if strings.ToLower(c) != "y" { + os.Exit(0) + } + } + + selected = profiles[index] + return +} + +// FormPrompt prompts the user to fill in the ssh profile fields, and returns the new profile. +func FormPrompt() *Profile { + profiles := GetProfiles() + var newProfile = new(Profile) + + fmt.Println("Add a new ssh profile, fill in the following fields (q to quit):") + + var promptLabels = []string{"Name(*)", "Host(*)", "Port", "User", "PrivateKey"} + m := make(map[string]string, len(promptLabels)) + + for _, p := range promptLabels { + prompt := promptui.Prompt{ + Label: p, + } + switch p { + case "Name": + prompt.Validate = func(input string) error { + input = strings.Trim(input, " ") + if input == "" { + return fmt.Errorf("ssh profile %s is required", p) + } + + for _, p := range profiles { + if p.Name == input { + return fmt.Errorf("ssh profile name %s already exists", input) + } + } + return nil + } + case "Host": + prompt.Validate = func(input string) error { + input = strings.Trim(input, " ") + if input == "" { + return fmt.Errorf("ssh profile %s is required", p) + } + return nil + } + case "Port": + prompt.Default = " 22" + prompt.Validate = func(input string) error { + input = strings.Trim(input, " ") + if input == "q" { + os.Exit(0) + } + _, err := strconv.Atoi(input) + return err + } + case "PrivateKey": + prompt.Validate = func(input string) error { + input = strings.Trim(input, " ") + if input == "" { + return nil + } + if input == "q" { + os.Exit(0) + } + _, err := os.Stat(input) + return err + } + } + value, _ := prompt.Run() + if value == "q" { + os.Exit(0) + } + m[p] = strings.Trim(value, " ") + } + + newProfile.Name = m["Name(*)"] + newProfile.Host = m["Host(*)"] + newProfile.Port, _ = strconv.Atoi(m["Port"]) + newProfile.User = m["User"] + newProfile.PrivateKey = m["PrivateKey"] + + return newProfile +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2e36bbf --- /dev/null +++ b/main.go @@ -0,0 +1,10 @@ +/* +Copyright © 2024 Pone Ding +*/ +package main + +import "github.com/poneding/ssher/cmd" + +func main() { + cmd.Execute() +}