diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..33ec849 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,41 @@ +name: GGH CI + +on: + push: + branches: + - master + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Unit test + strategy: + matrix: + go-version: [ "1.22", "1.23" ] + os: [ ubuntu-latest ] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - name: Check Go code formatting + run: | + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + gofmt -s -l . + echo "Please format Go code by running: go fmt ./..." + exit 1 + fi + - name: Run tests + run: | + go vet ./... + go build ./... + go test -v -cover ./... diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..b2fe70b --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,31 @@ +name: Release + +on: + release: + types: [ published ] + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: git fetch --force --tags + - uses: actions/setup-go@v5 + with: + go-version: stable + - name: Generate release notes + continue-on-error: true + run: ./scripts/release-notes.sh ${{github.ref_name}} > ${{runner.temp}}/release_notes.txt + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean --release-notes=${{runner.temp}}/release_notes.txt + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fecf267 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# 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 +go.work.sum +\.idea \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..e0b450b --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,36 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# +# See https://goreleaser.com/customization/ for more information. +version: 2 +project_name: ggh + +before: + hooks: + - go mod tidy +builds: + - env: + - CGO_ENABLED=0 + binary: ggh + main: ./ + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + ldflags: + # The v prefix is stripped by goreleaser, so we need to add it back. + # https://goreleaser.com/customization/templates/#fnref:version-prefix + - "-s -w -X main.version=v{{ .Version }}" + +archives: + - format: binary + name_template: >- + {{ .ProjectName }}_{{- tolower .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end }} +checksum: + name_template: "checksums.txt" +snapshot: + name_template: "{{ incpatch .Version }}-next" +changelog: + use: github-native \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5002553 --- /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 2024 Binyamin Yawitz + + 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. \ No newline at end of file diff --git a/assets/ggh.png b/assets/ggh.png new file mode 100644 index 0000000..6d2f671 Binary files /dev/null and b/assets/ggh.png differ diff --git a/cmd/ggh.go b/cmd/ggh.go new file mode 100644 index 0000000..2c6f65f --- /dev/null +++ b/cmd/ggh.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "github.com/byawitz/ggh/internal/command" + "github.com/byawitz/ggh/internal/config" + "github.com/byawitz/ggh/internal/history" + "github.com/byawitz/ggh/internal/interactive" + "github.com/byawitz/ggh/internal/ssh" + "os" +) + +func Main() { + command.CheckSSH() + + args := os.Args[1:] + + action, value := command.Which() + switch action { + case command.InteractiveHistory: + args = history.Interactive() + case command.InteractiveConfig: + args = interactive.InteractiveConfig("") + case command.InteractiveConfigWithSearch: + args = interactive.InteractiveConfig(value) + case command.ListHistory: + history.Print() + return + case command.ListConfig: + config.Print() + return + default: + + } + + history.AddHistoryFromArgs(args) + ssh.Run(args) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bee7fa8 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/byawitz/ggh + +go 1.22.5 + +require ( + github.com/charmbracelet/bubbles v0.19.0 + github.com/charmbracelet/bubbletea v0.27.1 + github.com/charmbracelet/lipgloss v0.13.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.2.3 // indirect + github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/text v0.17.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4c046fc --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +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.19.0 h1:gKZkKXPP6GlDk6EcfujDK19PCQqRjaJZQ7QRERx1UF0= +github.com/charmbracelet/bubbles v0.19.0/go.mod h1:WILteEqZ+krG5c3ntGEMeG99nCupcuIk7V0/zOP0tOA= +github.com/charmbracelet/bubbletea v0.27.1 h1:/yhaJKX52pxG4jZVKCNWj/oq0QouPdXycriDRA6m6r8= +github.com/charmbracelet/bubbletea v0.27.1/go.mod h1:xc4gm5yv+7tbniEvQ0naiG9P3fzYhk16cTgDZQQW6YE= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= +github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= +github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= +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/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.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +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= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= diff --git a/install/unix.sh b/install/unix.sh new file mode 100644 index 0000000..b0c69c7 --- /dev/null +++ b/install/unix.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -e + diff --git a/install/windows.ps1 b/install/windows.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/internal/command/flags.go b/internal/command/flags.go new file mode 100644 index 0000000..10daa06 --- /dev/null +++ b/internal/command/flags.go @@ -0,0 +1,41 @@ +package command + +import ( + "os" +) + +type Action int + +const ( + PassThrough Action = iota + InteractiveHistory + InteractiveConfig + InteractiveConfigWithSearch + ListHistory + ListConfig +) + +func Which() (Action, string) { + if len(os.Args) == 1 { + return InteractiveHistory, "" + } + + if len(os.Args) == 2 { + switch os.Args[1] { + case "--history": + return ListHistory, "" + case "--config": + return ListConfig, "" + case "-": + return InteractiveConfig, "" + } + } + + if len(os.Args) == 3 { + if os.Args[1] == "-" { + return InteractiveConfigWithSearch, os.Args[2] + } + } + + return PassThrough, "" +} diff --git a/internal/command/validator.go b/internal/command/validator.go new file mode 100644 index 0000000..fe1560c --- /dev/null +++ b/internal/command/validator.go @@ -0,0 +1,13 @@ +package command + +import ( + "log" + "os/exec" +) + +func CheckSSH() { + _, err := exec.LookPath("ssh") + if err != nil { + log.Fatal("ssh is not installed") + } +} diff --git a/internal/config/file.go b/internal/config/file.go new file mode 100644 index 0000000..0c9cca2 --- /dev/null +++ b/internal/config/file.go @@ -0,0 +1,34 @@ +package config + +import ( + "os" + "path/filepath" +) + +func GetConfigFile() string { + userHomeDir, err := os.UserHomeDir() + + if err != nil { + return "" + } + + sshConfigDir := filepath.Join(userHomeDir, ".ssh") + + config, err := os.ReadFile(filepath.Join(sshConfigDir, "config")) + + return string(config) +} + +func GetConfig(name string) (SSHConfig, error) { + list, err := ParseWithSearch(name, GetConfigFile()) + if err != nil { + return SSHConfig{}, err + } + + for _, sshConfig := range list { + if sshConfig.Name == name { + return sshConfig, nil + } + } + return SSHConfig{}, nil +} diff --git a/internal/config/parser.go b/internal/config/parser.go new file mode 100644 index 0000000..01ce374 --- /dev/null +++ b/internal/config/parser.go @@ -0,0 +1,82 @@ +package config + +import ( + "fmt" + "github.com/byawitz/ggh/internal/theme" + "github.com/charmbracelet/bubbles/table" + "log" + "strings" +) + +type SSHConfig struct { + Name string `json:"name"` + Host string `json:"host"` + Port string `json:"port"` + User string `json:"user"` + Key string `json:"key"` +} + +func Parse(configFile string) ([]SSHConfig, error) { + return ParseWithSearch("", configFile) +} + +func ParseWithSearch(search string, configFile string) ([]SSHConfig, error) { + configsStrings := strings.Split(strings.ReplaceAll(configFile, "\r\n", "\n"), "Host ") + var configs = make([]SSHConfig, 0) + + for _, config := range configsStrings[1:] { + lines := strings.Split(config, "\n") + + if strings.Trim(lines[0], " ") == "" { + continue + } + + if search != "" && !strings.Contains(lines[0], search) { + continue + } + + sshConfig := SSHConfig{ + Name: lines[0], + Port: "", + User: "", + } + + for _, line := range lines { + line = strings.ReplaceAll(strings.TrimLeft(line, " "), "\t", "") + switch { + case strings.Contains(line, "Host"): + sshConfig.Host = strings.Split(line, " ")[1] + case strings.Contains(line, "Port"): + sshConfig.Port = strings.Split(line, " ")[1] + case strings.Contains(line, "User"): + sshConfig.User = strings.Split(line, " ")[1] + case strings.Contains(line, "IdentityFile"): + sshConfig.Key = strings.Split(line, " ")[1] + } + + } + configs = append(configs, sshConfig) + } + + return configs, nil +} + +func Print() { + list, err := Parse(GetConfigFile()) + + if err != nil { + log.Fatal(err) + } + + if len(list) == 0 { + fmt.Println("No configs found in ~/.ssh/config.") + return + } + + var rows []table.Row + for _, history := range list { + rows = append(rows, table.Row{history.Name, history.Host, history.Port, history.User, history.Key}) + } + fmt.Println(theme.PrintTable(rows, theme.PrintConfig)) + +} diff --git a/internal/config/parser_test.go b/internal/config/parser_test.go new file mode 100644 index 0000000..750ee74 --- /dev/null +++ b/internal/config/parser_test.go @@ -0,0 +1,48 @@ +package config + +import ( + "testing" +) + +var config = ` +Host host1 + HostName hos1.com + Port 6743 + User root + +Host host2 + HostName 192.168.1.11 + User root + NotSupported Key + IdentityFile c:\ssh_keys\id_rsa + +Host host3-prod + HostName ubuntu.com + Port 5369 + User ubuntu + IdentityFile ~/.ssh/id_rsa +` + +func TestParsing(t *testing.T) { + configs, err := Parse(config) + + if err != nil { + t.Fatalf("Parsing failed: %v", err) + } + + if len(configs) != 3 { + t.Errorf("Parsing config file failed: got %v, want %v\n", len(configs), 3) + } +} +func TestParsingWithSearch(t *testing.T) { + configs, err := ParseWithSearch("prod", config) + + if err != nil { + t.Fatalf("Parsing failed: %v", err) + } + + if len(configs) != 1 { + + t.Errorf("Parsing config file failed: got %v, want %v\n", len(configs), 1) + } +} diff --git a/internal/history/fetch.go b/internal/history/fetch.go new file mode 100644 index 0000000..c7f467d --- /dev/null +++ b/internal/history/fetch.go @@ -0,0 +1,109 @@ +package history + +import ( + "encoding/json" + "fmt" + "github.com/byawitz/ggh/internal/config" + "github.com/byawitz/ggh/internal/interactive" + "github.com/byawitz/ggh/internal/ssh" + "github.com/byawitz/ggh/internal/theme" + "github.com/charmbracelet/bubbles/table" + "log" + "os" + "time" +) + +type SSHHistory struct { + Connection config.SSHConfig `json:"connection"` + Date time.Time `json:"date"` +} + +func FetchWithDefaultFile() ([]SSHHistory, error) { + return Fetch(getFile()) +} + +func Fetch(file []byte) ([]SSHHistory, error) { + var historyList []SSHHistory + + if len(file) == 0 { + return historyList, nil + } + + err := json.Unmarshal(file, &historyList) + if err != nil { + return nil, err + } + + return historyList, nil +} +func Interactive() []string { + list, err := FetchWithDefaultFile() + + if err != nil { + log.Fatal(err) + } + + if len(list) == 0 { + fmt.Println("No history found.") + os.Exit(0) + } + + var rows []table.Row + currentTime := time.Now() + for _, history := range list { + rows = append(rows, table.Row{ + history.Connection.Host, + history.Connection.Port, + history.Connection.User, + history.Connection.Key, + fmt.Sprintf("%s", readableTime(currentTime.Sub(history.Date))), + }) + } + c := interactive.Select(rows, interactive.SelectHistory) + return ssh.GenerateCommandArgs(c) +} + +func Print() { + list, err := FetchWithDefaultFile() + + if err != nil { + log.Fatal(err) + } + + if len(list) == 0 { + fmt.Println("No history found.") + return + } + var rows []table.Row + currentTime := time.Now() + for _, history := range list { + rows = append(rows, table.Row{history.Connection.Name, + history.Connection.Host, + history.Connection.Port, + history.Connection.User, + history.Connection.Key, + fmt.Sprintf("%s", readableTime(currentTime.Sub(history.Date))), + }) + } + + fmt.Println(theme.PrintTable(rows, theme.PrintHistory)) +} + +func readableTime(d time.Duration) string { + if d.Seconds() < 60 { + return fmt.Sprintf("%d seconds ago", int(d.Seconds())) + } + if d.Minutes() < 60 { + return fmt.Sprintf("%d minutes ago", int(d.Minutes())) + } + + if d.Hours() < 24 { + return fmt.Sprintf("%d hours ago", int(d.Hours())) + } + + if days := int(d.Hours() / 24); days < 90 { + return fmt.Sprintf("%d days ago", days) + } + + return "Long time ago" +} diff --git a/internal/history/fetch_test.go b/internal/history/fetch_test.go new file mode 100644 index 0000000..bc22aff --- /dev/null +++ b/internal/history/fetch_test.go @@ -0,0 +1,55 @@ +package history + +import ( + "testing" +) + +var historyFile = ` +[ + { + "time": "2024-01-01 00:00:00 +0000 UTC", + "connection": { + "name": "stage", + "host": "host.name", + "key": "~/.ssh/id_rsa" + } + }, + { + "time": "2022-01-01 00:00:00 +0000 UTC", + "connection": { + "name": "production", + "host": "host2.name", + "port": "5412", + "user": "ubuntu" + } + } +] +` + +func TestParsing(t *testing.T) { + history, err := Fetch([]byte(historyFile)) + + if err != nil { + t.Fatalf("Parsing failed: %v", err) + } + + if len(history) != 2 { + t.Errorf("Parsing config file failed: got %v, want %v\n", len(history), 3) + } + + if history[0].Connection.Host != "host.name" { + t.Errorf("Parsing config file failed: got %v, want %v\n", history[0].Connection.Host, "host.name") + } + + if history[0].Connection.Port != "" { + t.Errorf("Parsing config file failed: got %v, want %v\n", history[0].Connection.Port, "") + } + + if history[0].Connection.User != "" { + t.Errorf("Parsing config file failed: got %v, want %v\n", history[0].Connection.Port, "") + } + + if history[1].Connection.Host != "host2.name" { + t.Errorf("Parsing config file failed: got %v, want %v\n", history[1].Connection.Host, "host.name") + } +} diff --git a/internal/history/file.go b/internal/history/file.go new file mode 100644 index 0000000..ac73f2d --- /dev/null +++ b/internal/history/file.go @@ -0,0 +1,33 @@ +package history + +import ( + "os" + "path/filepath" +) + +func getFileLocation() string { + userHomeDir, err := os.UserHomeDir() + + if err != nil { + return "" + } + + gghConfigDir := filepath.Join(userHomeDir, ".ggh") + + if err := os.MkdirAll(gghConfigDir, 0700); err != nil { + return "" + } + + return filepath.Join(gghConfigDir, "history.json") + +} +func getFile() []byte { + + history, err := os.ReadFile(getFileLocation()) + + if err != nil { + return []byte{} + } + + return history +} diff --git a/internal/history/save.go b/internal/history/save.go new file mode 100644 index 0000000..07e474d --- /dev/null +++ b/internal/history/save.go @@ -0,0 +1,98 @@ +package history + +import ( + "encoding/json" + "fmt" + "github.com/byawitz/ggh/internal/config" + "os" + "slices" + "strings" + "time" +) + +func AddHistoryFromArgs(args []string) { + if len(args) == 1 { + localConfig, err := config.GetConfig(args[0]) + if err != nil || localConfig.Name == "" { + fmt.Printf("couldn't fetch %s from config file, error:%v.\n", args[0], err) + return + } + + AddHistory(localConfig) + return + } + + generatedConfig := config.SSHConfig{} + + skipNext := false + for i, arg := range args { + if skipNext { + skipNext = false + continue + } + + switch { + case arg == "-p": + generatedConfig.Port = args[i+1] + skipNext = true + case arg == "-i": + generatedConfig.Key = args[i+1] + skipNext = true + case strings.Contains(arg, "@"): + values := strings.Split(arg, "@") + generatedConfig.User = values[0] + generatedConfig.Host = values[1] + } + } + + AddHistory(generatedConfig) +} + +func AddHistory(c config.SSHConfig) { + if c.Host == "" { + return + } + + list, err := Fetch(getFile()) + + if err != nil { + fmt.Println("error getting ggh file") + return + } + + err = saveFile(SSHHistory{Connection: c, Date: time.Now()}, list) + if err != nil { + fmt.Println("error saving ggh file") + return + } +} + +func saveFile(n SSHHistory, l []SSHHistory) error { + file := getFileLocation() + fileContent := stringify(n, l) + + err := os.WriteFile(file, []byte(fileContent), 0644) + + return err +} + +func stringify(n SSHHistory, l []SSHHistory) string { + history := make([]SSHHistory, 0) + + for i, sshHistory := range l { + if sshHistory.Connection.Host == n.Connection.Host && + sshHistory.Connection.Name == n.Connection.Name { + l = slices.Delete(l, i, i+1) + } + } + + history = append(history, n) + history = append(history, l...) + content, err := json.Marshal(history) + + if err != nil { + return "" + } + + return string(content) +} diff --git a/internal/history/save_test.go b/internal/history/save_test.go new file mode 100644 index 0000000..184bf53 --- /dev/null +++ b/internal/history/save_test.go @@ -0,0 +1,28 @@ +package history + +import ( + "github.com/byawitz/ggh/internal/config" + "testing" + "time" +) + +var converted = "[{\"connection\":{\"name\":\"\",\"host\":\"\",\"port\":\"5172\",\"user\":\"\",\"key\":\"\"},\"date\":\"2024-08-25T00:00:00-04:00\"},{\"connection\":{\"name\":\"prod\",\"host\":\"myhost.com\",\"port\":\"\",\"user\":\"\",\"key\":\"\"},\"date\":\"2024-04-25T00:00:00-04:00\"}]" + +func TestMarshal(t *testing.T) { + history := []SSHHistory{ + { + Connection: config.SSHConfig{Host: "myhost.com", Name: "prod"}, + Date: time.Unix(1714017600, 0), + }, + } + + newHistory := SSHHistory{ + Connection: config.SSHConfig{Port: "5172"}, + Date: time.Unix(1724558400, 0), + } + + jsonString := stringify(newHistory, history) + if jsonString != converted { + t.Errorf("marshal json fail. Got %v, want %v", jsonString, converted) + } +} diff --git a/internal/interactive/config.go b/internal/interactive/config.go new file mode 100644 index 0000000..f4db74b --- /dev/null +++ b/internal/interactive/config.go @@ -0,0 +1,30 @@ +package interactive + +import ( + "fmt" + "github.com/byawitz/ggh/internal/config" + "github.com/byawitz/ggh/internal/ssh" + "github.com/charmbracelet/bubbles/table" + "os" +) + +func InteractiveConfig(value string) []string { + list, err := config.ParseWithSearch(value, config.GetConfigFile()) + if err != nil || len(list) == 0 { + fmt.Println("No config found.") + os.Exit(0) + } + + var rows []table.Row + for _, c := range list { + rows = append(rows, table.Row{ + c.Name, + c.Host, + c.Port, + c.User, + c.Key, + }) + } + c := Select(rows, SelectConfig) + return ssh.GenerateCommandArgs(c) +} diff --git a/internal/interactive/select.go b/internal/interactive/select.go new file mode 100644 index 0000000..efeef19 --- /dev/null +++ b/internal/interactive/select.go @@ -0,0 +1,125 @@ +package interactive + +import ( + "fmt" + "github.com/byawitz/ggh/internal/config" + "github.com/byawitz/ggh/internal/theme" + "math" + "os" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Selecting int + +const ( + SelectConfig Selecting = iota + SelectHistory +) + +type model struct { + table table.Model + choice config.SSHConfig + what Selecting + exit bool +} + +func (m model) Init() tea.Cmd { return nil } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + m.exit = true + return m, tea.Quit + case "enter": + m.choice = setConfig(m.table.SelectedRow(), m.what) + return m, tea.Quit + } + } + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +func setConfig(row table.Row, what Selecting) config.SSHConfig { + if what == SelectConfig { + return config.SSHConfig{ + Host: row[1], + Port: row[2], + User: row[3], + Key: row[4], + } + } + + return config.SSHConfig{ + Host: row[0], + Port: row[1], + User: row[2], + Key: row[3], + } +} + +func (m model) View() string { + if m.choice.Host != "" || m.exit { + return "" + } + return theme.BaseStyle.Render(m.table.View()) + "\n " + m.table.HelpView() + "\n" +} + +func Select(rows []table.Row, what Selecting) config.SSHConfig { + var columns []table.Column + if what == SelectConfig { + columns = append(columns, []table.Column{ + {Title: "Name", Width: 15}, + {Title: "Host", Width: 15}, + {Title: "Port", Width: 10}, + {Title: "User", Width: 10}, + {Title: "Key", Width: 10}, + }...) + } + + if what == SelectHistory { + columns = append(columns, []table.Column{ + {Title: "Host", Width: 15}, + {Title: "Port", Width: 10}, + {Title: "User", Width: 10}, + {Title: "Key", Width: 10}, + {Title: "Last login", Width: 15}, + }...) + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(int(math.Min(8, float64(len(rows)+1)))), + ) + + 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) + + p := tea.NewProgram(model{table: t, what: what}) + m, err := p.Run() + if err != nil { + fmt.Println("error while running the interactive selector, ", err) + os.Exit(1) + } + // Assert the final tea.Model to our local model and print the choice. + if m, ok := m.(model); ok { + if m.choice.Host != "" { + return m.choice + } + if m.exit { + os.Exit(0) + } + } + + return config.SSHConfig{} +} diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go new file mode 100644 index 0000000..323ad65 --- /dev/null +++ b/internal/ssh/ssh.go @@ -0,0 +1,35 @@ +package ssh + +import ( + "fmt" + "github.com/byawitz/ggh/internal/config" + "os" + "os/exec" + "strings" +) + +func GenerateCommandArgs(c config.SSHConfig) []string { + key, port := "", "" + user := "root" + + if c.User != "" { + user = c.User + } + + if c.Key != "" { + key = "-i " + c.Key + } + + if c.Port != "" { + key = "-p " + c.Port + } + return strings.Split(fmt.Sprintf("%s@%s %s %s", user, c.Host, key, port), " ") +} + +func Run(args []string) { + cmd := exec.Command("ssh", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() +} diff --git a/internal/theme/style.go b/internal/theme/style.go new file mode 100644 index 0000000..d2d5df2 --- /dev/null +++ b/internal/theme/style.go @@ -0,0 +1,7 @@ +package theme + +import "github.com/charmbracelet/lipgloss" + +var BaseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")) diff --git a/internal/theme/tables.go b/internal/theme/tables.go new file mode 100644 index 0000000..062aa3a --- /dev/null +++ b/internal/theme/tables.go @@ -0,0 +1,40 @@ +package theme + +import ( + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/lipgloss" +) + +type Print int + +const ( + PrintConfig = iota + PrintHistory +) + +func PrintTable(rows []table.Row, p Print) string { + columns := []table.Column{ + {Title: "Name", Width: 10}, + {Title: "Host", Width: 15}, + {Title: "Port", Width: 10}, + {Title: "User", Width: 10}, + {Title: "Key", Width: 10}, + } + + if p == PrintHistory { + columns = append(columns, table.Column{Title: "Last login", Width: 15}) + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(false), + table.WithStyles(table.Styles{ + Header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), + Selected: lipgloss.NewStyle(), + }), + table.WithHeight(len(rows)+1), + ) + + return BaseStyle.Render(t.View()) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..18d24f2 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/byawitz/ggh/cmd" + +func main() { + cmd.Main() +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..644a861 --- /dev/null +++ b/readme.md @@ -0,0 +1,34 @@ +
+ +
+GGH + +Recall your SSH sessions
+ +## Install + +Run one of the following script, or download the binary from the [latest release](https://github.com/byawitz/ggh/releases) page. + +```shell + +# Go +go install github.com/byawitz/ggh@latest +``` + +## Usages + +```shell +# Use it just like you're using SSH +ggh root@server.com +ggh root@server.com -p2440 + +# Run it with no arguments to get interactive list of the previous sessions +ggh + +# Run it with - to get interactive list of all of your ~/.ssh/config listing +ggh - + +# Run it with - STRING to get interactive filtered list of your ~/.ssh/config listing +ggh - stage +ggh - meta-servers +``` \ No newline at end of file diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100644 index 0000000..2f87683 --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +set -euo pipefail + +# Check if the required argument is provided +if [ $# -lt 1 ]; then + echo "Usage: $0