├── .github └── workflows │ ├── go.yaml │ ├── release.yaml │ └── sh.yaml ├── .gitignore ├── .gitmodules ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── cli │ └── main.go ├── collect │ └── main.go ├── config │ └── main.go ├── control │ ├── cmd │ │ ├── doctor.go │ │ ├── root.go │ │ ├── update.go │ │ └── version.go │ └── main.go ├── daemon │ ├── dump.go │ ├── main.go │ ├── record.go │ ├── run-server.go │ ├── session-init.go │ └── status.go ├── generate-uuid │ └── main.go ├── get-epochtime │ └── main.go ├── install-utils │ ├── device.go │ ├── main.go │ └── migrate.go ├── postcollect │ └── main.go └── session-init │ └── main.go ├── go.mod ├── go.sum ├── img ├── graph-command-sequences.png ├── screen-recall.png ├── screen-resh-cli-v2-6-no-query.png ├── screen-resh-cli-v2-6.png ├── screen-resh-cli-v2-7-init.png ├── screen-resh-cli-v2-7-no-query.png ├── screen-resh-cli-v2-7.png ├── screen-resh-cli.png └── screen.png ├── installation.md ├── internal ├── cfg │ ├── cfg.go │ └── migrate.go ├── check │ └── check.go ├── collect │ └── collect.go ├── datadir │ └── datadir.go ├── device │ └── device.go ├── epochtime │ ├── epochtime.go │ └── epochtime_test.go ├── futil │ └── futil.go ├── histcli │ └── histcli.go ├── histfile │ └── histfile.go ├── histio │ ├── file.go │ └── histio.go ├── histlist │ └── histlist.go ├── httpclient │ └── httpclient.go ├── logger │ └── logger.go ├── msg │ └── msg.go ├── normalize │ ├── normailze.go │ └── normalize_test.go ├── opt │ └── opt.go ├── output │ └── output.go ├── recconv │ └── recconv.go ├── recio │ ├── read.go │ ├── recio.go │ └── write.go ├── recordint │ ├── collect.go │ ├── recordint.go │ └── searchapp.go ├── records │ └── records.go ├── recutil │ └── recutil.go ├── searchapp │ ├── highlight.go │ ├── item.go │ ├── item_test.go │ ├── query.go │ └── time.go ├── sess │ └── sess.go ├── sesswatch │ └── sesswatch.go ├── signalhandler │ └── signalhander.go └── status │ └── status.go ├── record ├── legacy.go ├── record.go └── v1.go ├── scripts ├── hooks.sh ├── install.sh ├── rawinstall.sh ├── resh-daemon-restart.sh ├── resh-daemon-start.sh ├── resh-daemon-stop.sh ├── shellrc.sh └── test.sh └── troubleshooting.md /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push] 3 | jobs: 4 | 5 | test: 6 | name: Test 7 | runs-on: ubuntu-latest 8 | steps: 9 | 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 0 14 | 15 | - name: Get Go version 16 | run: echo "GO_VERSION=$(grep '^go ' go.mod | cut -d ' ' -f 2)" >> $GITHUB_ENV && cat $GITHUB_ENV 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v3 20 | with: 21 | go-version: ${{ env.GO_VERSION }} 22 | 23 | - name: Test 24 | run: go test -v ./... 25 | 26 | - name: Vet 27 | run: go vet ./... 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | create: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | 10 | goreleaser: 11 | name: Goreleaser 12 | runs-on: ubuntu-latest 13 | steps: 14 | 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Get Go version 21 | run: echo "GO_VERSION=$(grep '^go ' go.mod | cut -d ' ' -f 2)" >> $GITHUB_ENV && cat $GITHUB_ENV 22 | 23 | - name: Set up Go 24 | uses: actions/setup-go@v3 25 | with: 26 | go-version: ${{ env.GO_VERSION }} 27 | 28 | - name: Checkout submodules 29 | run: git submodule update --init --recursive 30 | 31 | - name: Run GoReleaser 32 | uses: goreleaser/goreleaser-action@v2 33 | with: 34 | version: latest 35 | args: release 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/sh.yaml: -------------------------------------------------------------------------------- 1 | name: Shell 2 | on: [push] 3 | jobs: 4 | 5 | test: 6 | name: Test 7 | runs-on: ubuntu-latest 8 | steps: 9 | 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 0 14 | 15 | - name: Set up 16 | run: sudo apt-get install -y shellcheck zsh 17 | 18 | - name: Test 19 | run: scripts/test.sh 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/* 2 | .vscode/* -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "submodules/bash-preexec"] 2 | path = submodules/bash-preexec 3 | url = https://github.com/rcaloras/bash-preexec.git 4 | [submodule "submodules/bash-zsh-compat-widgets"] 5 | path = submodules/bash-zsh-compat-widgets 6 | url = https://github.com/curusarn/bash-zsh-compat-widgets.git 7 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # .goreleaser.yml 2 | project_name: resh 3 | 4 | # resh_template: '{{ ProjectName }}_{{ .Version }}' 5 | # env: 6 | # - GO111MODULE=on 7 | # before: 8 | # hooks: 9 | # - go mod tidy 10 | 11 | builds: 12 | # You can have multiple builds defined as a yaml list 13 | # ID of the build. 14 | # Defaults to the project name. 15 | # id: "daemon" 16 | 17 | # Path to main.go file or main package. 18 | # Default is `.`. 19 | # id: ./cmd/daemon 20 | 21 | # Binary name. 22 | # Can be a path (e.g. `bin/app`) to wrap the binary in a directory. 23 | # Default is the name of the project directory. 24 | # binary: resh-collect 25 | 26 | # Hooks can be used to customize the final binary, 27 | # for example, to run generators. 28 | # Those fields allow templates. 29 | # Default is both hooks empty. 30 | # hooks: 31 | # pre: rice embed-go 32 | # post: ./script.sh 33 | - 34 | id: "cli" 35 | main: ./cmd/cli 36 | binary: bin/resh-cli 37 | goarch: 38 | - 386 39 | - amd64 40 | - arm 41 | - arm64 42 | - 43 | id: "collect" 44 | main: ./cmd/collect 45 | binary: bin/resh-collect 46 | goarch: 47 | - 386 48 | - amd64 49 | - arm 50 | - arm64 51 | - 52 | id: "config" 53 | main: ./cmd/config 54 | binary: bin/resh-config 55 | goarch: 56 | - 386 57 | - amd64 58 | - arm 59 | - arm64 60 | - 61 | id: "control" 62 | main: ./cmd/control 63 | binary: bin/resh-control 64 | goarch: 65 | - 386 66 | - amd64 67 | - arm 68 | - arm64 69 | - 70 | id: "daemon" 71 | main: ./cmd/daemon 72 | binary: bin/resh-daemon 73 | goarch: 74 | - 386 75 | - amd64 76 | - arm 77 | - arm64 78 | - 79 | id: "postcollect" 80 | main: ./cmd/postcollect 81 | binary: bin/resh-postcollect 82 | goarch: 83 | - 386 84 | - amd64 85 | - arm 86 | - arm64 87 | - 88 | id: "session-init" 89 | main: ./cmd/session-init 90 | binary: bin/resh-session-init 91 | goarch: 92 | - 386 93 | - amd64 94 | - arm 95 | - arm64 96 | - 97 | id: "install-utils" 98 | main: ./cmd/install-utils 99 | binary: bin/resh-install-utils 100 | goarch: 101 | - 386 102 | - amd64 103 | - arm 104 | - arm64 105 | - 106 | id: "generate-uuid" 107 | main: ./cmd/generate-uuid 108 | binary: bin/resh-generate-uuid 109 | goarch: 110 | - 386 111 | - amd64 112 | - arm 113 | - arm64 114 | - 115 | id: "get-epochtime" 116 | main: ./cmd/get-epochtime 117 | binary: bin/resh-get-epochtime 118 | goarch: 119 | - 386 120 | - amd64 121 | - arm 122 | - arm64 123 | 124 | # signs: 125 | # - artifacts: checksum 126 | 127 | # include everything in the package 128 | # looks like xmas 129 | archives: 130 | - files: 131 | - ./Makefile 132 | - conf/* 133 | - conf/**/* 134 | - data/* 135 | - data/**/* 136 | - scripts/* 137 | - scripts/**/* 138 | - submodules/**/* 139 | 140 | release: 141 | # draft: true 142 | 143 | # If set to auto, will mark the release as not ready for production 144 | # in case there is an indicator for this in the tag e.g. v1.0.0-rc1 145 | # If set to true, will mark the release as not ready for production. 146 | # Default is false. 147 | prerelease: auto 148 | # disable: true 149 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Šimon Let 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash 2 | LATEST_TAG=$(shell git describe --tags) 3 | VERSION:="${LATEST_TAG}-$(shell date +%s)" 4 | COMMIT:=$(shell [ -z "$(git status --untracked-files=no --porcelain)" ] && git rev-parse --short=12 HEAD || echo "no_commit") 5 | GOFLAGS=-ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.development=true" 6 | 7 | build: submodules bin/resh-session-init bin/resh-collect bin/resh-postcollect\ 8 | bin/resh-daemon bin/resh-control bin/resh-config bin/resh-cli\ 9 | bin/resh-install-utils bin/resh-generate-uuid bin/resh-get-epochtime 10 | 11 | # We disable jobserver for the actual installation because we want it to run serially 12 | # Make waits to the daemon process we launch during install and hangs 13 | install: build 14 | scripts/install.sh 15 | 16 | # Rebuild binaries and install 17 | # Very useful to ensure that all binaries get new VERSION variable which is used for shell config reloading 18 | clean_install: 19 | make clean 20 | make build 21 | make install 22 | 23 | test: 24 | go test -v ./... 25 | go vet ./... 26 | scripts/test.sh 27 | 28 | rebuild: 29 | make clean 30 | make build 31 | 32 | clean: 33 | rm -f -- bin/* 34 | 35 | uninstall: 36 | # Uninstalling ... 37 | -rm -rf -- ~/.resh/ 38 | 39 | go_files = $(shell find -name '*.go') 40 | bin/resh-%: $(go_files) 41 | grep $@ .goreleaser.yml -q # all build targets need to be included in .goreleaser.yml 42 | go build ${GOFLAGS} -o $@ cmd/$*/*.go 43 | 44 | .PHONY: submodules build install rebuild uninstall clean test 45 | 46 | submodules: | submodules/bash-preexec/bash-preexec.sh submodules/bash-zsh-compat-widgets/bindfunc.sh 47 | @# sets submodule.recurse to true if unset 48 | @# sets status.submoduleSummary to true if unset 49 | @git config --get submodule.recurse >/dev/null || git config --global submodule.recurse true 50 | @#git config --get status.submoduleSummary >/dev/null || git config --global status.submoduleSummary true 51 | @#git config --get diff.submodule >/dev/null || git config --global diff.submodule log 52 | @# warns user if submodule.recurse is not set to true 53 | @[[ "true" == `git config --get submodule.recurse` ]] || echo "WARN: You should REALLY set 'git config --global submodule.recurse true'!" 54 | @#git config --global push.recurseSubmodules check 55 | 56 | submodules/%: 57 | # Getting submodules ... 58 | git submodule sync --recursive 59 | git submodule update --init --recursive 60 | 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Latest version](https://img.shields.io/github/v/tag/curusarn/resh?sort=semver)](https://github.com/curusarn/resh/releases) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/curusarn/resh)](https://goreportcard.com/report/github.com/curusarn/resh) 4 | [![Go test](https://github.com/curusarn/resh/actions/workflows/go.yaml/badge.svg)](https://github.com/curusarn/resh/actions/workflows/go.yaml) 5 | [![Shell test](https://github.com/curusarn/resh/actions/workflows/sh.yaml/badge.svg)](https://github.com/curusarn/resh/actions/workflows/sh.yaml) 6 | 7 | # RESH 8 | 9 | Context-based replacement for `zsh` and `bash` shell history. 10 | 11 | **Full-text search your shell history.** 12 | Relevant results are displayed first based on current directory, git repo, and exit status. 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ## Install 26 | 27 | Install RESH with one command: 28 | 29 | ```sh 30 | curl -fsSL https://raw.githubusercontent.com/curusarn/resh/master/scripts/rawinstall.sh | bash 31 | ``` 32 | 33 | ℹ️ You will need to have `bash`, `curl`, and `tar` installed. 34 | 35 | More options on [Installation page ⇗](./installation.md) 36 | 37 | ## Search your history 38 | 39 | Press Ctrl + R to search: 40 | 41 | RESH search app screenshot 42 | 43 | ### In-app key bindings 44 | 45 | - Type to search 46 | - Up / Down or Ctrl + P / Ctrl + N to select results 47 | - Enter to execute selected command 48 | - Right to paste selected command onto the command line so you can edit it before execution 49 | - Ctrl + C or Ctrl + D to quit 50 | - Ctrl + G to abort and paste the current query onto the command line 51 | - Ctrl + R to search without context (toggle) 52 | 53 | ## Issues & ideas 54 | 55 | Find help on [Troubleshooting page ⇗](./troubleshooting.md) 56 | 57 | Problem persists? [Create an issue ⇗](https://github.com/curusarn/resh/issues) 58 | -------------------------------------------------------------------------------- /cmd/cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "os" 12 | "sort" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "github.com/awesome-gocui/gocui" 18 | "github.com/curusarn/resh/internal/cfg" 19 | "github.com/curusarn/resh/internal/datadir" 20 | "github.com/curusarn/resh/internal/device" 21 | "github.com/curusarn/resh/internal/logger" 22 | "github.com/curusarn/resh/internal/msg" 23 | "github.com/curusarn/resh/internal/opt" 24 | "github.com/curusarn/resh/internal/output" 25 | "github.com/curusarn/resh/internal/recordint" 26 | "github.com/curusarn/resh/internal/searchapp" 27 | "github.com/spf13/pflag" 28 | "go.uber.org/zap" 29 | 30 | "strconv" 31 | ) 32 | 33 | // info passed during build 34 | var version string 35 | var commit string 36 | var development string 37 | 38 | // special constant recognized by RESH wrappers 39 | const exitCodeExecute = 111 40 | 41 | func main() { 42 | config, errCfg := cfg.New() 43 | logger, err := logger.New("search-app", config.LogLevel, development) 44 | if err != nil { 45 | fmt.Printf("Error while creating logger: %v", err) 46 | } 47 | defer logger.Sync() // flushes buffer, if any 48 | if errCfg != nil { 49 | logger.Error("Error while getting configuration", zap.Error(errCfg)) 50 | } 51 | out := output.New(logger, "resh-search-app ERROR") 52 | 53 | output, exitCode := runReshCli(out, config) 54 | fmt.Print(output) 55 | os.Exit(exitCode) 56 | } 57 | 58 | func runReshCli(out *output.Output, config cfg.Config) (string, int) { 59 | args := opt.HandleVersionOpts(out, os.Args, version, commit) 60 | 61 | const missing = "" 62 | flags := pflag.NewFlagSet("", pflag.ExitOnError) 63 | sessionID := flags.String("session-id", missing, "RESH generated session ID") 64 | pwd := flags.String("pwd", missing, "$PWD - present working directory") 65 | gitOriginRemote := flags.String("git-remote", missing, "> git remote get-url origin") 66 | query := flags.String("query", "", "Search query") 67 | flags.Parse(args) 68 | 69 | // TODO: These errors should tell the user that they should not be running the command directly 70 | errMsg := "Failed to get required command-line arguments" 71 | if *sessionID == missing { 72 | out.FatalE(errMsg, errors.New("missing required option --session-id")) 73 | } 74 | if *pwd == missing { 75 | out.FatalE(errMsg, errors.New("missing required option --pwd")) 76 | } 77 | if *gitOriginRemote == missing { 78 | out.FatalE(errMsg, errors.New("missing required option --git-origin-remote")) 79 | } 80 | dataDir, err := datadir.GetPath() 81 | if err != nil { 82 | out.FatalE("Could not get user data directory", err) 83 | } 84 | deviceName, err := device.GetName(dataDir) 85 | if err != nil { 86 | out.FatalE("Could not get device name", err) 87 | } 88 | 89 | g, err := gocui.NewGui(gocui.OutputNormal, false) 90 | if err != nil { 91 | out.FatalE("Failed to launch TUI", err) 92 | } 93 | defer g.Close() 94 | 95 | g.Cursor = true 96 | // g.SelFgColor = gocui.ColorGreen 97 | // g.SelBgColor = gocui.ColorGreen 98 | g.Highlight = true 99 | 100 | var resp msg.CliResponse 101 | mess := msg.CliMsg{ 102 | SessionID: *sessionID, 103 | PWD: *pwd, 104 | } 105 | resp = SendCliMsg(out, mess, strconv.Itoa(config.Port)) 106 | 107 | st := state{ 108 | // lock sync.Mutex 109 | gui: g, 110 | cliRecords: resp.Records, 111 | initialQuery: *query, 112 | } 113 | 114 | // TODO: Use device ID 115 | layout := manager{ 116 | out: out, 117 | config: config, 118 | sessionID: *sessionID, 119 | host: deviceName, 120 | pwd: *pwd, 121 | gitOriginRemote: *gitOriginRemote, 122 | s: &st, 123 | } 124 | g.SetManager(layout) 125 | 126 | errMsg = "Failed to set keybindings" 127 | if err := g.SetKeybinding("", gocui.KeyTab, gocui.ModNone, layout.Next); err != nil { 128 | out.FatalE(errMsg, err) 129 | } 130 | if err := g.SetKeybinding("", gocui.KeyArrowDown, gocui.ModNone, layout.Next); err != nil { 131 | out.FatalE(errMsg, err) 132 | } 133 | if err := g.SetKeybinding("", gocui.KeyCtrlN, gocui.ModNone, layout.Next); err != nil { 134 | out.FatalE(errMsg, err) 135 | } 136 | if err := g.SetKeybinding("", gocui.KeyArrowUp, gocui.ModNone, layout.Prev); err != nil { 137 | out.FatalE(errMsg, err) 138 | } 139 | if err := g.SetKeybinding("", gocui.KeyCtrlP, gocui.ModNone, layout.Prev); err != nil { 140 | out.FatalE(errMsg, err) 141 | } 142 | 143 | if err := g.SetKeybinding("", gocui.KeyArrowRight, gocui.ModNone, layout.SelectPaste); err != nil { 144 | out.FatalE(errMsg, err) 145 | } 146 | if err := g.SetKeybinding("", gocui.KeyEnter, gocui.ModNone, layout.SelectExecute); err != nil { 147 | out.FatalE(errMsg, err) 148 | } 149 | if err := g.SetKeybinding("", gocui.KeyCtrlG, gocui.ModNone, layout.AbortPaste); err != nil { 150 | out.FatalE(errMsg, err) 151 | } 152 | if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil { 153 | out.FatalE(errMsg, err) 154 | } 155 | if err := g.SetKeybinding("", gocui.KeyCtrlD, gocui.ModNone, quit); err != nil { 156 | out.FatalE(errMsg, err) 157 | } 158 | 159 | if err := g.SetKeybinding("", gocui.KeyCtrlR, gocui.ModNone, layout.SwitchModes); err != nil { 160 | out.FatalE(errMsg, err) 161 | } 162 | 163 | ctx := context.Background() 164 | layout.updateData(ctx, *query) 165 | layout.updateRawData(ctx, *query) 166 | err = g.MainLoop() 167 | if err != nil && !errors.Is(err, gocui.ErrQuit) { 168 | out.FatalE("Main application loop finished with error", err) 169 | } 170 | return layout.s.output, layout.s.exitCode 171 | } 172 | 173 | type state struct { 174 | gui *gocui.Gui 175 | 176 | cliRecords []recordint.SearchApp 177 | 178 | lock sync.Mutex 179 | 180 | cancelUpdate *context.CancelFunc 181 | data []searchapp.Item 182 | rawData []searchapp.RawItem 183 | highlightedItem int 184 | displayedItemsCount int 185 | 186 | rawMode bool 187 | 188 | initialQuery string 189 | 190 | output string 191 | exitCode int 192 | } 193 | 194 | type manager struct { 195 | out *output.Output 196 | config cfg.Config 197 | 198 | sessionID string 199 | host string 200 | pwd string 201 | gitOriginRemote string 202 | 203 | s *state 204 | } 205 | 206 | func (m manager) SelectExecute(g *gocui.Gui, v *gocui.View) error { 207 | m.s.lock.Lock() 208 | defer m.s.lock.Unlock() 209 | if m.s.rawMode { 210 | if m.s.highlightedItem < len(m.s.rawData) { 211 | m.s.output = m.s.rawData[m.s.highlightedItem].CmdLineOut 212 | m.s.exitCode = exitCodeExecute 213 | return gocui.ErrQuit 214 | } 215 | } else { 216 | if m.s.highlightedItem < len(m.s.data) { 217 | m.s.output = m.s.data[m.s.highlightedItem].CmdLineOut 218 | m.s.exitCode = exitCodeExecute 219 | return gocui.ErrQuit 220 | } 221 | } 222 | return nil 223 | } 224 | 225 | func (m manager) SelectPaste(g *gocui.Gui, v *gocui.View) error { 226 | m.s.lock.Lock() 227 | defer m.s.lock.Unlock() 228 | if m.s.rawMode { 229 | if m.s.highlightedItem < len(m.s.rawData) { 230 | m.s.output = m.s.rawData[m.s.highlightedItem].CmdLineOut 231 | m.s.exitCode = 0 // success 232 | return gocui.ErrQuit 233 | } 234 | } else { 235 | if m.s.highlightedItem < len(m.s.data) { 236 | m.s.output = m.s.data[m.s.highlightedItem].CmdLineOut 237 | m.s.exitCode = 0 // success 238 | return gocui.ErrQuit 239 | } 240 | } 241 | return nil 242 | } 243 | 244 | func (m manager) AbortPaste(g *gocui.Gui, v *gocui.View) error { 245 | m.s.lock.Lock() 246 | defer m.s.lock.Unlock() 247 | if m.s.highlightedItem < len(m.s.data) { 248 | m.s.output = v.Buffer() 249 | m.s.exitCode = 0 // success 250 | return gocui.ErrQuit 251 | } 252 | return nil 253 | } 254 | 255 | func (m manager) updateData(ctx context.Context, input string) { 256 | timeStart := time.Now() 257 | sugar := m.out.Logger.Sugar() 258 | sugar.Debugw("Starting data update ...", 259 | "recordCount", len(m.s.cliRecords), 260 | "itemCount", len(m.s.data), 261 | ) 262 | query := searchapp.NewQueryFromString(sugar, input, m.host, m.pwd, m.gitOriginRemote, m.config.Debug) 263 | var data []searchapp.Item 264 | itemSet := make(map[string]int) 265 | for _, rec := range m.s.cliRecords { 266 | if shouldCancel(ctx) { 267 | timeEnd := time.Now() 268 | sugar.Infow("Update got canceled", 269 | "duration", timeEnd.Sub(timeStart), 270 | ) 271 | return 272 | } 273 | itm, err := searchapp.NewItemFromRecordForQuery(rec, query, m.config.Debug) 274 | if err != nil { 275 | // records didn't match the query 276 | // sugar.Println(" * continue (no match)", rec.Pwd) 277 | continue 278 | } 279 | if idx, ok := itemSet[itm.Key]; ok { 280 | // duplicate found 281 | if data[idx].Score >= itm.Score { 282 | // skip duplicate item 283 | continue 284 | } 285 | // update duplicate item 286 | data[idx] = itm 287 | continue 288 | } 289 | // add new item 290 | itemSet[itm.Key] = len(data) 291 | data = append(data, itm) 292 | } 293 | sugar.Debugw("Got new items from records for query, sorting items ...", 294 | "itemCount", len(data), 295 | ) 296 | sort.SliceStable(data, func(p, q int) bool { 297 | return data[p].Score > data[q].Score 298 | }) 299 | 300 | m.s.lock.Lock() 301 | defer m.s.lock.Unlock() 302 | m.s.data = nil 303 | for _, itm := range data { 304 | if len(m.s.data) > 420 { 305 | break 306 | } 307 | m.s.data = append(m.s.data, itm) 308 | } 309 | m.s.highlightedItem = 0 310 | timeEnd := time.Now() 311 | sugar.Debugw("Done with data update", 312 | "duration", timeEnd.Sub(timeStart), 313 | "recordCount", len(m.s.cliRecords), 314 | "itemCount", len(m.s.data), 315 | "input", input, 316 | ) 317 | } 318 | 319 | func (m manager) updateRawData(ctx context.Context, input string) { 320 | timeStart := time.Now() 321 | sugar := m.out.Logger.Sugar() 322 | sugar.Debugw("Starting RAW data update ...", 323 | "recordCount", len(m.s.cliRecords), 324 | "itemCount", len(m.s.data), 325 | ) 326 | query := searchapp.GetRawTermsFromString(input, m.config.Debug) 327 | var data []searchapp.RawItem 328 | itemSet := make(map[string]bool) 329 | for _, rec := range m.s.cliRecords { 330 | if shouldCancel(ctx) { 331 | timeEnd := time.Now() 332 | sugar.Debugw("Update got canceled", 333 | "duration", timeEnd.Sub(timeStart), 334 | ) 335 | return 336 | } 337 | itm, err := searchapp.NewRawItemFromRecordForQuery(rec, query, m.config.Debug) 338 | if err != nil { 339 | // records didn't match the query 340 | // sugar.Println(" * continue (no match)", rec.Pwd) 341 | continue 342 | } 343 | if itemSet[itm.Key] { 344 | // sugar.Println(" * continue (already present)", itm.key(), itm.pwd) 345 | continue 346 | } 347 | itemSet[itm.Key] = true 348 | data = append(data, itm) 349 | // sugar.Println("DATA =", itm.display) 350 | } 351 | sugar.Debugw("Got new RAW items from records for query, sorting items ...", 352 | "itemCount", len(data), 353 | ) 354 | sort.SliceStable(data, func(p, q int) bool { 355 | return data[p].Score > data[q].Score 356 | }) 357 | m.s.lock.Lock() 358 | defer m.s.lock.Unlock() 359 | m.s.rawData = nil 360 | for _, itm := range data { 361 | if len(m.s.rawData) > 420 { 362 | break 363 | } 364 | m.s.rawData = append(m.s.rawData, itm) 365 | } 366 | m.s.highlightedItem = 0 367 | timeEnd := time.Now() 368 | sugar.Debugw("Done with RAW data update", 369 | "duration", timeEnd.Sub(timeStart), 370 | "recordCount", len(m.s.cliRecords), 371 | "itemCount", len(m.s.data), 372 | ) 373 | } 374 | 375 | func shouldCancel(ctx context.Context) bool { 376 | select { 377 | case <-ctx.Done(): 378 | return true 379 | default: 380 | return false 381 | } 382 | } 383 | 384 | func (m manager) getCtxAndCancel() context.Context { 385 | m.s.lock.Lock() 386 | defer m.s.lock.Unlock() 387 | if m.s.cancelUpdate != nil { 388 | (*m.s.cancelUpdate)() 389 | } 390 | ctx, cancel := context.WithCancel(context.Background()) 391 | m.s.cancelUpdate = &cancel 392 | return ctx 393 | } 394 | 395 | func (m manager) update(input string) { 396 | ctx := m.getCtxAndCancel() 397 | if m.s.rawMode { 398 | m.updateRawData(ctx, input) 399 | } else { 400 | m.updateData(ctx, input) 401 | } 402 | m.flush() 403 | } 404 | 405 | func (m manager) flush() { 406 | f := func(_ *gocui.Gui) error { return nil } 407 | m.s.gui.Update(f) 408 | } 409 | 410 | func (m manager) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 411 | gocui.DefaultEditor.Edit(v, key, ch, mod) 412 | go m.update(v.Buffer()) 413 | } 414 | 415 | func (m manager) Next(g *gocui.Gui, v *gocui.View) error { 416 | m.s.lock.Lock() 417 | defer m.s.lock.Unlock() 418 | if m.s.highlightedItem < m.s.displayedItemsCount-1 { 419 | m.s.highlightedItem++ 420 | } 421 | return nil 422 | } 423 | 424 | func (m manager) Prev(g *gocui.Gui, v *gocui.View) error { 425 | m.s.lock.Lock() 426 | defer m.s.lock.Unlock() 427 | if m.s.highlightedItem > 0 { 428 | m.s.highlightedItem-- 429 | } 430 | return nil 431 | } 432 | 433 | func (m manager) SwitchModes(g *gocui.Gui, v *gocui.View) error { 434 | m.s.lock.Lock() 435 | m.s.rawMode = !m.s.rawMode 436 | m.s.lock.Unlock() 437 | 438 | go m.update(v.Buffer()) 439 | return nil 440 | } 441 | 442 | func (m manager) Layout(g *gocui.Gui) error { 443 | var b byte 444 | maxX, maxY := g.Size() 445 | 446 | v, err := g.SetView("input", 0, 0, maxX-1, 2, b) 447 | if err != nil && !errors.Is(err, gocui.ErrUnknownView) { 448 | m.out.FatalE("Failed to set view 'input'", err) 449 | } 450 | 451 | v.Editable = true 452 | v.Editor = m 453 | if m.s.rawMode { 454 | v.Title = " RESH SEARCH - NON-CONTEXTUAL \"RAW\" MODE - (CTRL+R to switch BACK) " 455 | } else { 456 | v.Title = " RESH SEARCH - CONTEXTUAL MODE - (CTRL+R to switch to RAW MODE) " 457 | } 458 | 459 | g.SetCurrentView("input") 460 | 461 | m.s.lock.Lock() 462 | defer m.s.lock.Unlock() 463 | if len(m.s.initialQuery) > 0 { 464 | v.WriteString(m.s.initialQuery) 465 | v.SetCursor(len(m.s.initialQuery), 0) 466 | m.s.initialQuery = "" 467 | } 468 | 469 | v, err = g.SetView("body", 0, 2, maxX-1, maxY, b) 470 | if err != nil && !errors.Is(err, gocui.ErrUnknownView) { 471 | m.out.FatalE("Failed to set view 'body'", err) 472 | } 473 | v.Frame = false 474 | v.Autoscroll = false 475 | v.Clear() 476 | v.Rewind() 477 | 478 | if m.s.rawMode { 479 | return m.rawMode(g, v) 480 | } 481 | return m.normalMode(g, v) 482 | } 483 | 484 | func quit(g *gocui.Gui, v *gocui.View) error { 485 | return gocui.ErrQuit 486 | } 487 | 488 | const smallTerminalThresholdWidth = 110 489 | 490 | func (m manager) normalMode(g *gocui.Gui, v *gocui.View) error { 491 | sugar := m.out.Logger.Sugar() 492 | maxX, maxY := g.Size() 493 | 494 | compactRenderingMode := false 495 | if maxX < smallTerminalThresholdWidth { 496 | compactRenderingMode = true 497 | } 498 | 499 | data := []searchapp.ItemColumns{} 500 | 501 | header := searchapp.GetHeader(compactRenderingMode) 502 | longestDateLen := len(header.Date) 503 | longestLocationLen := len(header.Host) + 1 + len(header.PwdTilde) 504 | longestFlagsLen := 2 505 | maxPossibleMainViewHeight := maxY - 3 - 1 - 1 - 1 // - top box - header - status - help 506 | for i, itm := range m.s.data { 507 | if i == maxY { 508 | break 509 | } 510 | ic := itm.DrawItemColumns(compactRenderingMode, m.config.Debug) 511 | data = append(data, ic) 512 | if i > maxPossibleMainViewHeight { 513 | // do not stretch columns because of results that will end up outside of the page 514 | continue 515 | } 516 | if len(ic.Date) > longestDateLen { 517 | longestDateLen = len(ic.Date) 518 | } 519 | if len(ic.Host)+len(ic.PwdTilde) > longestLocationLen { 520 | longestLocationLen = len(ic.Host) + len(ic.PwdTilde) 521 | } 522 | if len(ic.Flags) > longestFlagsLen { 523 | longestFlagsLen = len(ic.Flags) 524 | } 525 | } 526 | maxLocationLen := maxX/7 + 8 527 | if longestLocationLen > maxLocationLen { 528 | longestLocationLen = maxLocationLen 529 | } 530 | 531 | if m.s.highlightedItem >= len(m.s.data) { 532 | m.s.highlightedItem = len(m.s.data) - 1 533 | } 534 | // status line 535 | topBoxHeight := 3 // size of the query box up top 536 | topBoxHeight++ // headers 537 | realLineLength := maxX - 2 538 | printedLineLength := maxX - 4 539 | statusLine := searchapp.GetEmptyStatusLine(printedLineLength, realLineLength) 540 | if m.s.highlightedItem != -1 && m.s.highlightedItem < len(m.s.data) { 541 | statusLine = m.s.data[m.s.highlightedItem].DrawStatusLine(compactRenderingMode, printedLineLength, realLineLength) 542 | } 543 | var statusLineHeight int = len(statusLine) 544 | 545 | helpLineHeight := 1 546 | const helpLine = "HELP: type to search, UP/DOWN or CTRL+P/N to select, RIGHT to edit, ENTER to execute, CTRL+G to abort, CTRL+C/D to quit; " + 547 | "FLAGS: G = this git repo, E# = exit status #" 548 | // "TIP: when resh-cli is launched command line is used as initial search query" 549 | 550 | mainViewHeight := maxY - topBoxHeight - statusLineHeight - helpLineHeight 551 | m.s.displayedItemsCount = mainViewHeight 552 | 553 | // header 554 | // header := getHeader() 555 | // error is expected for header 556 | dispStr, _, _ := header.ProduceLine(longestDateLen, longestLocationLen, longestFlagsLen, true, true, m.config.Debug) 557 | dispStr = searchapp.DoHighlightHeader(dispStr, maxX*2) 558 | v.WriteString(dispStr + "\n") 559 | 560 | var index int 561 | for index < len(data) { 562 | itm := data[index] 563 | if index >= mainViewHeight { 564 | sugar.Debugw("Reached bottom of the page while producing lines", 565 | "mainViewHeight", mainViewHeight, 566 | "predictedMaxViewHeight", maxPossibleMainViewHeight, 567 | ) 568 | // page is full 569 | break 570 | } 571 | 572 | displayStr, _, err := itm.ProduceLine(longestDateLen, longestLocationLen, longestFlagsLen, false, true, m.config.Debug) 573 | if err != nil { 574 | sugar.Error("Error while drawing item", zap.Error(err)) 575 | } 576 | if m.s.highlightedItem == index { 577 | // maxX * 2 because there are escape sequences that make it hard to tell the real string length 578 | displayStr = searchapp.DoHighlightString(displayStr, maxX*3) 579 | } 580 | if strings.Contains(displayStr, "\n") { 581 | displayStr = strings.ReplaceAll(displayStr, "\n", "#") 582 | } 583 | v.WriteString(displayStr + "\n") 584 | index++ 585 | } 586 | // push the status line to the bottom of the page 587 | for index < mainViewHeight { 588 | v.WriteString("\n") 589 | index++ 590 | } 591 | for _, line := range statusLine { 592 | v.WriteString(line) 593 | } 594 | v.WriteString(helpLine) 595 | sugar.Debugw("Done drawing page", 596 | "itemCount", len(m.s.data), 597 | "highlightedItemIndex", m.s.highlightedItem, 598 | ) 599 | return nil 600 | } 601 | 602 | func (m manager) rawMode(g *gocui.Gui, v *gocui.View) error { 603 | sugar := m.out.Logger.Sugar() 604 | maxX, maxY := g.Size() 605 | topBoxSize := 3 606 | m.s.displayedItemsCount = maxY - topBoxSize 607 | 608 | for i, itm := range m.s.rawData { 609 | if i == maxY { 610 | break 611 | } 612 | displayStr := itm.CmdLineWithColor 613 | if m.s.highlightedItem == i { 614 | // Use actual min required length instead of 420 constant 615 | displayStr = searchapp.DoHighlightString(displayStr, maxX*2) 616 | } 617 | if strings.Contains(displayStr, "\n") { 618 | displayStr = strings.ReplaceAll(displayStr, "\n", "#") 619 | } 620 | v.WriteString(displayStr + "\n") 621 | } 622 | sugar.Debugw("Done drawing page in RAW mode", 623 | "itemCount", len(m.s.data), 624 | "highlightedItemIndex", m.s.highlightedItem, 625 | ) 626 | return nil 627 | } 628 | 629 | // SendCliMsg to daemon 630 | func SendCliMsg(out *output.Output, m msg.CliMsg, port string) msg.CliResponse { 631 | sugar := out.Logger.Sugar() 632 | recJSON, err := json.Marshal(m) 633 | if err != nil { 634 | out.FatalE("Failed to marshal message", err) 635 | } 636 | 637 | req, err := http.NewRequest( 638 | "POST", 639 | "http://localhost:"+port+"/dump", 640 | bytes.NewBuffer(recJSON)) 641 | if err != nil { 642 | out.FatalE("Failed to build request", err) 643 | } 644 | req.Header.Set("Content-Type", "application/json") 645 | 646 | client := http.Client{ 647 | Timeout: 3 * time.Second, 648 | } 649 | resp, err := client.Do(req) 650 | if err != nil { 651 | out.FatalDaemonNotRunning(err) 652 | } 653 | 654 | defer resp.Body.Close() 655 | body, err := io.ReadAll(resp.Body) 656 | if err != nil { 657 | out.FatalE("Failed read response", err) 658 | } 659 | // sugar.Println(string(body)) 660 | response := msg.CliResponse{} 661 | err = json.Unmarshal(body, &response) 662 | if err != nil { 663 | out.FatalE("Failed decode response", err) 664 | } 665 | sugar.Debugw("Received records from daemon", 666 | "recordCount", len(response.Records), 667 | ) 668 | return response 669 | } 670 | -------------------------------------------------------------------------------- /cmd/collect/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/curusarn/resh/internal/cfg" 8 | "github.com/curusarn/resh/internal/collect" 9 | "github.com/curusarn/resh/internal/logger" 10 | "github.com/curusarn/resh/internal/opt" 11 | "github.com/curusarn/resh/internal/output" 12 | "github.com/curusarn/resh/internal/recordint" 13 | "github.com/curusarn/resh/record" 14 | "github.com/spf13/pflag" 15 | "go.uber.org/zap" 16 | 17 | "path/filepath" 18 | "strconv" 19 | ) 20 | 21 | // info passed during build 22 | var version string 23 | var commit string 24 | var development string 25 | 26 | func main() { 27 | config, errCfg := cfg.New() 28 | logger, err := logger.New("collect", config.LogLevel, development) 29 | if err != nil { 30 | fmt.Printf("Error while creating logger: %v", err) 31 | } 32 | defer logger.Sync() // flushes buffer, if any 33 | if errCfg != nil { 34 | logger.Error("Error while getting configuration", zap.Error(errCfg)) 35 | } 36 | out := output.New(logger, "resh-collect ERROR") 37 | 38 | args := opt.HandleVersionOpts(out, os.Args, version, commit) 39 | 40 | flags := pflag.NewFlagSet("", pflag.ExitOnError) 41 | cmdLine := flags.String("cmd-line", "", "Command line") 42 | gitRemote := flags.String("git-remote", "", "> git remote get-url origin") 43 | home := flags.String("home", "", "$HOME") 44 | pwd := flags.String("pwd", "", "$PWD - present working directory") 45 | recordID := flags.String("record-id", "", "RESH generated record ID") 46 | sessionID := flags.String("session-id", "", "RESH generated session ID") 47 | sessionPID := flags.Int("session-pid", -1, "$$ - Shell session PID") 48 | shell := flags.String("shell", "", "Current shell") 49 | shlvl := flags.Int("shlvl", -1, "$SHLVL") 50 | timeStr := flags.String("time", "-1", "$EPOCHREALTIME") 51 | flags.Parse(args) 52 | 53 | time, err := strconv.ParseFloat(*timeStr, 64) 54 | if err != nil { 55 | out.FatalE("Error while parsing flag --time", err) 56 | } 57 | 58 | realPwd, err := filepath.EvalSymlinks(*pwd) 59 | if err != nil { 60 | out.ErrorE("Error while evaluating symlinks in PWD", err) 61 | realPwd = "" 62 | } 63 | 64 | rec := recordint.Collect{ 65 | SessionID: *sessionID, 66 | Shlvl: *shlvl, 67 | SessionPID: *sessionPID, 68 | 69 | Shell: *shell, 70 | 71 | Rec: record.V1{ 72 | SessionID: *sessionID, 73 | RecordID: *recordID, 74 | 75 | CmdLine: *cmdLine, 76 | 77 | // posix 78 | Home: *home, 79 | Pwd: *pwd, 80 | RealPwd: realPwd, 81 | 82 | GitOriginRemote: *gitRemote, 83 | 84 | Time: fmt.Sprintf("%.4f", time), 85 | 86 | PartOne: true, 87 | PartsNotMerged: true, 88 | }, 89 | } 90 | collect.SendRecord(out, rec, strconv.Itoa(config.Port), "/record") 91 | } 92 | -------------------------------------------------------------------------------- /cmd/config/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/curusarn/resh/internal/cfg" 10 | "github.com/curusarn/resh/internal/logger" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // info passed during build 15 | var version string 16 | var commit string 17 | var development string 18 | 19 | func main() { 20 | config, errCfg := cfg.New() 21 | logger, _ := logger.New("config", config.LogLevel, development) 22 | defer logger.Sync() // flushes buffer, if any 23 | if errCfg != nil { 24 | logger.Error("Error while getting configuration", zap.Error(errCfg)) 25 | } 26 | 27 | configKey := flag.String("key", "", "Key of the requested config entry") 28 | flag.Parse() 29 | 30 | if *configKey == "" { 31 | fmt.Println("Error: expected option --key!") 32 | os.Exit(1) 33 | } 34 | 35 | *configKey = strings.ToLower(*configKey) 36 | switch *configKey { 37 | case "bindcontrolr": 38 | printBoolNormalized(config.BindControlR) 39 | case "port": 40 | fmt.Println(config.Port) 41 | case "sesswatchperiodseconds": 42 | fmt.Println(config.SessionWatchPeriodSeconds) 43 | case "sesshistinithistorysize": 44 | fmt.Println(config.ReshHistoryMinSize) 45 | default: 46 | fmt.Println("Error: illegal --key!") 47 | os.Exit(1) 48 | } 49 | } 50 | 51 | // this might be unnecessary but I'm too lazy to look it up 52 | func printBoolNormalized(x bool) { 53 | if x { 54 | fmt.Println("true") 55 | } else { 56 | fmt.Println("false") 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /cmd/control/cmd/doctor.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "time" 8 | 9 | "github.com/curusarn/resh/internal/cfg" 10 | "github.com/curusarn/resh/internal/check" 11 | "github.com/curusarn/resh/internal/msg" 12 | "github.com/curusarn/resh/internal/status" 13 | "github.com/spf13/cobra" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | func doctorCmdFunc(config cfg.Config) func(*cobra.Command, []string) { 18 | return func(cmd *cobra.Command, args []string) { 19 | allOK := true 20 | if !checkDaemon(config) { 21 | allOK = false 22 | printDivider() 23 | } 24 | if !checkShellSession() { 25 | allOK = false 26 | printDivider() 27 | } 28 | if !checkShells() { 29 | allOK = false 30 | printDivider() 31 | } 32 | 33 | if allOK { 34 | out.Info("Everything looks good.") 35 | } 36 | } 37 | } 38 | 39 | func printDivider() { 40 | fmt.Printf("\n") 41 | } 42 | 43 | var msgFailedDaemonStart = `Failed to start RESH daemon. 44 | -> Start RESH daemon manually - run: resh-daemon-start 45 | -> Or restart this terminal window to bring RESH daemon back up 46 | -> You can check logs: ~/.local/share/resh/log.json (or ~/$XDG_DATA_HOME/resh/log.json) 47 | -> You can create an issue at: https://github.com/curusarn/resh/issues 48 | ` 49 | 50 | func checkDaemon(config cfg.Config) bool { 51 | ok := true 52 | resp, err := status.GetDaemonStatus(config.Port) 53 | if err != nil { 54 | out.InfoE("RESH Daemon is not running", err) 55 | out.Info("Attempting to start RESH daemon ...") 56 | resp, err = startDaemon(config.Port, 5, 200*time.Millisecond) 57 | if err != nil { 58 | out.InfoE(msgFailedDaemonStart, err) 59 | return false 60 | } 61 | ok = false 62 | out.Info("Successfully started daemon.") 63 | } 64 | if version != resp.Version { 65 | out.InfoDaemonVersionMismatch(version, resp.Version) 66 | return false 67 | } 68 | return ok 69 | } 70 | 71 | func startDaemon(port int, maxRetries int, backoff time.Duration) (*msg.StatusResponse, error) { 72 | err := exec.Command("resh-daemon-start").Run() 73 | if err != nil { 74 | return nil, err 75 | } 76 | var resp *msg.StatusResponse 77 | retry := 0 78 | for { 79 | time.Sleep(backoff) 80 | resp, err = status.GetDaemonStatus(port) 81 | if err == nil { 82 | break 83 | } 84 | if retry == maxRetries { 85 | return nil, err 86 | } 87 | out.Logger.Error("Failed to get daemon status - retrying", zap.Error(err), zap.Int("retry", retry)) 88 | retry++ 89 | continue 90 | } 91 | return resp, nil 92 | } 93 | 94 | var msgShellFilesNotLoaded = `RESH shell files were not properly loaded in this terminal 95 | -> Try restarting this terminal to see if the issue persists 96 | -> Check your shell rc files (e.g. .zshrc, .bashrc, ...) 97 | -> You can create an issue at: https://github.com/curusarn/resh/issues 98 | ` 99 | 100 | func checkShellSession() bool { 101 | versionEnv, found := os.LookupEnv("__RESH_VERSION") 102 | if !found { 103 | out.Info(msgShellFilesNotLoaded) 104 | return false 105 | } 106 | if version != versionEnv { 107 | out.InfoTerminalVersionMismatch(version, versionEnv) 108 | return false 109 | } 110 | return true 111 | } 112 | 113 | func checkShells() bool { 114 | allOK := true 115 | 116 | msg, err := check.LoginShell() 117 | if err != nil { 118 | out.InfoE("Failed to get login shell", err) 119 | allOK = false 120 | } 121 | if msg != "" { 122 | out.Info(msg) 123 | allOK = false 124 | } 125 | 126 | msg, err = check.ZshVersion() 127 | if err != nil { 128 | out.InfoE("Failed to check zsh version", err) 129 | allOK = false 130 | } 131 | if msg != "" { 132 | out.Info(msg) 133 | allOK = false 134 | } 135 | 136 | msg, err = check.BashVersion() 137 | if err != nil { 138 | out.InfoE("Failed to check bash version", err) 139 | allOK = false 140 | } 141 | if msg != "" { 142 | out.Info(msg) 143 | allOK = false 144 | } 145 | 146 | return allOK 147 | } 148 | -------------------------------------------------------------------------------- /cmd/control/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/curusarn/resh/internal/cfg" 7 | "github.com/curusarn/resh/internal/logger" 8 | "github.com/curusarn/resh/internal/output" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var version string 13 | var commit string 14 | 15 | // globals 16 | var out *output.Output 17 | 18 | var rootCmd = &cobra.Command{ 19 | Use: "reshctl", 20 | Short: "Reshctl (RESH control) - check status, update", 21 | } 22 | 23 | // Execute reshctl 24 | func Execute(ver, com, development string) { 25 | version = ver 26 | commit = com 27 | 28 | config, errCfg := cfg.New() 29 | logger, err := logger.New("reshctl", config.LogLevel, development) 30 | if err != nil { 31 | fmt.Printf("Error while creating logger: %v", err) 32 | } 33 | defer logger.Sync() // flushes buffer, if any 34 | out = output.New(logger, "ERROR") 35 | if errCfg != nil { 36 | out.ErrorE("Error while getting configuration", errCfg) 37 | } 38 | 39 | var versionCmd = cobra.Command{ 40 | Use: "version", 41 | Short: "show RESH version", 42 | Run: versionCmdFunc(config), 43 | } 44 | rootCmd.AddCommand(&versionCmd) 45 | 46 | doctorCmd := cobra.Command{ 47 | Use: "doctor", 48 | Short: "check common problems", 49 | Run: doctorCmdFunc(config), 50 | } 51 | rootCmd.AddCommand(&doctorCmd) 52 | 53 | updateCmd.Flags().BoolVar(&betaFlag, "beta", false, "Update to latest version even if it's beta.") 54 | rootCmd.AddCommand(updateCmd) 55 | 56 | if err := rootCmd.Execute(); err != nil { 57 | out.FatalE("Command ended with error", err) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /cmd/control/cmd/update.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "path/filepath" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var betaFlag bool 12 | var updateCmd = &cobra.Command{ 13 | Use: "update", 14 | Short: "check for updates and update RESH", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | homeDir, err := os.UserHomeDir() 17 | if err != nil { 18 | out.FatalE("Could not get user home dir", err) 19 | } 20 | rawinstallPath := filepath.Join(homeDir, ".resh/rawinstall.sh") 21 | execArgs := []string{rawinstallPath} 22 | if betaFlag { 23 | execArgs = append(execArgs, "--beta") 24 | } 25 | execCmd := exec.Command("bash", execArgs...) 26 | execCmd.Stdin = os.Stdin 27 | execCmd.Stdout = os.Stdout 28 | execCmd.Stderr = os.Stderr 29 | err = execCmd.Run() 30 | if err != nil { 31 | out.FatalE("Update ended with error", err) 32 | } 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /cmd/control/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/curusarn/resh/internal/cfg" 8 | "github.com/curusarn/resh/internal/status" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func versionCmdFunc(config cfg.Config) func(*cobra.Command, []string) { 13 | return func(cmd *cobra.Command, args []string) { 14 | 15 | fmt.Printf("Installed: %s\n", version) 16 | 17 | versionEnv := getEnvVarWithDefault("__RESH_VERSION", "") 18 | fmt.Printf("This terminal session: %s\n", version) 19 | 20 | resp, err := status.GetDaemonStatus(config.Port) 21 | if err != nil { 22 | fmt.Printf("Running checks: %s\n", version) 23 | out.ErrorDaemonNotRunning(err) 24 | return 25 | } 26 | fmt.Printf("Currently running daemon: %s\n", resp.Version) 27 | 28 | if version != resp.Version { 29 | out.ErrorDaemonVersionMismatch(version, resp.Version) 30 | return 31 | } 32 | if version != versionEnv { 33 | out.ErrorTerminalVersionMismatch(version, versionEnv) 34 | return 35 | } 36 | } 37 | } 38 | 39 | func getEnvVarWithDefault(varName, defaultValue string) string { 40 | val, found := os.LookupEnv(varName) 41 | if !found { 42 | return defaultValue 43 | } 44 | return val 45 | } 46 | -------------------------------------------------------------------------------- /cmd/control/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/curusarn/resh/cmd/control/cmd" 5 | ) 6 | 7 | var version string 8 | var commit string 9 | var development string 10 | 11 | func main() { 12 | cmd.Execute(version, commit, development) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/daemon/dump.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/curusarn/resh/internal/histfile" 9 | "github.com/curusarn/resh/internal/msg" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | type dumpHandler struct { 14 | sugar *zap.SugaredLogger 15 | histfileBox *histfile.Histfile 16 | } 17 | 18 | func (h *dumpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 19 | sugar := h.sugar.With(zap.String("endpoint", "/dump")) 20 | sugar.Debugw("Handling request, reading body ...") 21 | jsn, err := io.ReadAll(r.Body) 22 | if err != nil { 23 | sugar.Errorw("Error reading body", "error", err) 24 | return 25 | } 26 | 27 | sugar.Debugw("Unmarshaling record ...") 28 | mess := msg.CliMsg{} 29 | err = json.Unmarshal(jsn, &mess) 30 | if err != nil { 31 | sugar.Errorw("Error during unmarshaling", 32 | "error", err, 33 | "payload", jsn, 34 | ) 35 | return 36 | } 37 | sugar.Debugw("Getting records to send ...") 38 | fullRecords := h.histfileBox.DumpCliRecords() 39 | if err != nil { 40 | sugar.Errorw("Error when getting records", "error", err) 41 | } 42 | 43 | resp := msg.CliResponse{Records: fullRecords.List} 44 | jsn, err = json.Marshal(&resp) 45 | if err != nil { 46 | sugar.Errorw("Error when marshaling", "error", err) 47 | return 48 | } 49 | w.Write(jsn) 50 | sugar.Infow("Request handled") 51 | } 52 | -------------------------------------------------------------------------------- /cmd/daemon/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/curusarn/resh/internal/cfg" 12 | "github.com/curusarn/resh/internal/datadir" 13 | "github.com/curusarn/resh/internal/device" 14 | "github.com/curusarn/resh/internal/logger" 15 | "github.com/curusarn/resh/internal/status" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | // info passed during build 20 | var version string 21 | var commit string 22 | var development string 23 | 24 | const helpMsg = `ERROR: resh-daemon doesn't accept any arguments 25 | 26 | WARNING: 27 | You shouldn't typically need to start RESH daemon yourself. 28 | Unless its already running, RESH daemon is started when a new terminal is opened. 29 | RESH daemon will not start if it's already running even when you run it manually. 30 | 31 | USAGE: 32 | $ resh-daemon 33 | Runs the daemon as foreground process. You can kill it with CTRL+C. 34 | 35 | $ resh-daemon-start 36 | Runs the daemon as background process detached from terminal. 37 | 38 | LOGS & DEBUGGING: 39 | Logs are located in: 40 | ${XDG_DATA_HOME}/resh/log.json (if XDG_DATA_HOME is set) 41 | ~/.local/share/resh/log.json (otherwise - more common) 42 | 43 | A good way to see the logs as they are being produced is: 44 | $ tail -f ~/.local/share/resh/log.json 45 | 46 | MORE INFO: 47 | https://github.com/curusarn/resh/ 48 | ` 49 | 50 | func main() { 51 | if len(os.Args) > 1 { 52 | fmt.Fprint(os.Stderr, helpMsg) 53 | os.Exit(1) 54 | } 55 | config, errCfg := cfg.New() 56 | logger, err := logger.New("daemon", config.LogLevel, development) 57 | if err != nil { 58 | fmt.Printf("Error while creating logger: %v", err) 59 | } 60 | defer logger.Sync() // flushes buffer, if any 61 | if errCfg != nil { 62 | logger.Error("Error while getting configuration", zap.Error(errCfg)) 63 | } 64 | sugar := logger.Sugar() 65 | d := daemon{sugar: sugar} 66 | sugar.Infow("Daemon starting ...", 67 | "version", version, 68 | "commit", commit, 69 | ) 70 | dataDir, err := datadir.MakePath() 71 | if err != nil { 72 | sugar.Fatalw("Could not get user data directory", zap.Error(err)) 73 | } 74 | homeDir, err := os.UserHomeDir() 75 | if err != nil { 76 | sugar.Fatalw("Could not get user home directory", zap.Error(err)) 77 | } 78 | // TODO: These paths should be probably defined in a package 79 | pidFile := filepath.Join(dataDir, "daemon.pid") 80 | reshHistoryPath := filepath.Join(dataDir, datadir.HistoryFileName) 81 | bashHistoryPath := filepath.Join(homeDir, ".bash_history") 82 | zshHistoryPath := filepath.Join(homeDir, ".zsh_history") 83 | deviceID, err := device.GetID(dataDir) 84 | if err != nil { 85 | sugar.Fatalw("Could not get resh device ID", zap.Error(err)) 86 | } 87 | deviceName, err := device.GetName(dataDir) 88 | if err != nil { 89 | sugar.Fatalw("Could not get resh device name", zap.Error(err)) 90 | } 91 | 92 | sugar = sugar.With(zap.Int("daemonPID", os.Getpid())) 93 | 94 | res, err := status.IsDaemonRunning(config.Port) 95 | if err != nil { 96 | sugar.Errorw("Error while checking daemon status - it's probably not running", 97 | "error", err) 98 | } 99 | if res { 100 | sugar.Errorw("Daemon is already running - exiting!") 101 | return 102 | } 103 | _, err = os.Stat(pidFile) 104 | if err == nil { 105 | sugar.Warnw("PID file exists", 106 | "PIDFile", pidFile) 107 | // kill daemon 108 | err = d.killDaemon(pidFile) 109 | if err != nil { 110 | sugar.Errorw("Could not kill daemon", 111 | "error", err, 112 | ) 113 | } 114 | } 115 | err = os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())), 0644) 116 | if err != nil { 117 | sugar.Fatalw("Could not create PID file", 118 | "error", err, 119 | "PIDFile", pidFile, 120 | ) 121 | } 122 | server := Server{ 123 | sugar: sugar, 124 | config: config, 125 | reshHistoryPath: reshHistoryPath, 126 | bashHistoryPath: bashHistoryPath, 127 | zshHistoryPath: zshHistoryPath, 128 | 129 | deviceID: deviceID, 130 | deviceName: deviceName, 131 | } 132 | server.Run() 133 | sugar.Infow("Removing PID file ...", 134 | "PIDFile", pidFile, 135 | ) 136 | err = os.Remove(pidFile) 137 | if err != nil { 138 | sugar.Errorw("Could not delete PID file", "error", err) 139 | } 140 | sugar.Info("Shutting down ...") 141 | } 142 | 143 | type daemon struct { 144 | sugar *zap.SugaredLogger 145 | } 146 | 147 | func (d *daemon) killDaemon(pidFile string) error { 148 | dat, err := os.ReadFile(pidFile) 149 | if err != nil { 150 | d.sugar.Errorw("Reading PID file failed", 151 | "PIDFile", pidFile, 152 | "error", err) 153 | } 154 | d.sugar.Infow("Successfully read PID file", "contents", string(dat)) 155 | pid, err := strconv.Atoi(strings.TrimSuffix(string(dat), "\n")) 156 | if err != nil { 157 | return fmt.Errorf("could not parse PID file contents: %w", err) 158 | } 159 | d.sugar.Infow("Successfully parsed PID", "PID", pid) 160 | err = exec.Command("kill", "-SIGTERM", fmt.Sprintf("%d", pid)).Run() 161 | if err != nil { 162 | return fmt.Errorf("kill command finished with error: %w", err) 163 | } 164 | return nil 165 | } 166 | -------------------------------------------------------------------------------- /cmd/daemon/record.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/curusarn/resh/internal/recordint" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | func NewRecordHandler(sugar *zap.SugaredLogger, subscribers []chan recordint.Collect) recordHandler { 13 | return recordHandler{ 14 | sugar: sugar.With(zap.String("endpoint", "/record")), 15 | subscribers: subscribers, 16 | } 17 | } 18 | 19 | type recordHandler struct { 20 | sugar *zap.SugaredLogger 21 | subscribers []chan recordint.Collect 22 | 23 | deviceID string 24 | deviceName string 25 | } 26 | 27 | func (h *recordHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 28 | sugar := h.sugar.With(zap.String("endpoint", "/record")) 29 | sugar.Debugw("Handling request, sending response, reading body ...") 30 | w.Write([]byte("OK\n")) 31 | jsn, err := io.ReadAll(r.Body) 32 | // run rest of the handler as goroutine to prevent any hangups 33 | go func() { 34 | if err != nil { 35 | sugar.Errorw("Error reading body", "error", err) 36 | return 37 | } 38 | 39 | sugar.Debugw("Unmarshaling record ...") 40 | rec := recordint.Collect{} 41 | err = json.Unmarshal(jsn, &rec) 42 | if err != nil { 43 | sugar.Errorw("Error during unmarshaling", 44 | "error", err, 45 | "payload", jsn, 46 | ) 47 | return 48 | } 49 | part := "2" 50 | if rec.Rec.PartOne { 51 | part = "1" 52 | } 53 | sugar := sugar.With( 54 | "cmdLine", rec.Rec.CmdLine, 55 | "part", part, 56 | ) 57 | rec.Rec.DeviceID = h.deviceID 58 | rec.Rec.Device = h.deviceName 59 | sugar.Debugw("Got record, sending to subscribers ...") 60 | for _, sub := range h.subscribers { 61 | sub <- rec 62 | } 63 | sugar.Debugw("Record sent to subscribers") 64 | }() 65 | } 66 | -------------------------------------------------------------------------------- /cmd/daemon/run-server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/curusarn/resh/internal/cfg" 10 | "github.com/curusarn/resh/internal/histfile" 11 | "github.com/curusarn/resh/internal/recordint" 12 | "github.com/curusarn/resh/internal/sesswatch" 13 | "github.com/curusarn/resh/internal/signalhandler" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | // TODO: turn server and handlers into package 18 | 19 | type Server struct { 20 | sugar *zap.SugaredLogger 21 | config cfg.Config 22 | 23 | reshHistoryPath string 24 | bashHistoryPath string 25 | zshHistoryPath string 26 | 27 | deviceID string 28 | deviceName string 29 | } 30 | 31 | func (s *Server) Run() { 32 | var recordSubscribers []chan recordint.Collect 33 | var sessionInitSubscribers []chan recordint.SessionInit 34 | var sessionDropSubscribers []chan string 35 | var signalSubscribers []chan os.Signal 36 | 37 | shutdown := make(chan string) 38 | 39 | // histfile 40 | histfileRecords := make(chan recordint.Collect) 41 | recordSubscribers = append(recordSubscribers, histfileRecords) 42 | histfileSessionsToDrop := make(chan string) 43 | sessionDropSubscribers = append(sessionDropSubscribers, histfileSessionsToDrop) 44 | histfileSignals := make(chan os.Signal) 45 | signalSubscribers = append(signalSubscribers, histfileSignals) 46 | maxHistSize := 10000 // lines 47 | minHistSizeKB := 2000 // roughly lines 48 | histfileBox := histfile.New(s.sugar, histfileRecords, histfileSessionsToDrop, 49 | s.reshHistoryPath, s.bashHistoryPath, s.zshHistoryPath, 50 | maxHistSize, minHistSizeKB, 51 | histfileSignals, shutdown) 52 | 53 | // sesswatch 54 | sesswatchRecords := make(chan recordint.Collect) 55 | recordSubscribers = append(recordSubscribers, sesswatchRecords) 56 | sesswatchSessionsToWatch := make(chan recordint.SessionInit) 57 | sessionInitSubscribers = append(sessionInitSubscribers, sesswatchSessionsToWatch) 58 | sesswatch.Go( 59 | s.sugar, 60 | sesswatchSessionsToWatch, 61 | sesswatchRecords, 62 | sessionDropSubscribers, 63 | s.config.SessionWatchPeriodSeconds, 64 | ) 65 | 66 | // handlers 67 | mux := http.NewServeMux() 68 | mux.Handle("/status", &statusHandler{sugar: s.sugar}) 69 | mux.Handle("/record", &recordHandler{ 70 | sugar: s.sugar, 71 | subscribers: recordSubscribers, 72 | deviceID: s.deviceID, 73 | deviceName: s.deviceName, 74 | }) 75 | mux.Handle("/session_init", &sessionInitHandler{sugar: s.sugar, subscribers: sessionInitSubscribers}) 76 | mux.Handle("/dump", &dumpHandler{sugar: s.sugar, histfileBox: histfileBox}) 77 | 78 | server := &http.Server{ 79 | Addr: "localhost:" + strconv.Itoa(s.config.Port), 80 | Handler: mux, 81 | ReadTimeout: 1 * time.Second, 82 | WriteTimeout: 1 * time.Second, 83 | ReadHeaderTimeout: 1 * time.Second, 84 | IdleTimeout: 30 * time.Second, 85 | } 86 | go server.ListenAndServe() 87 | 88 | // signalhandler - takes over the main goroutine so when signal handler exists the whole program exits 89 | signalhandler.Run(s.sugar, signalSubscribers, shutdown, server) 90 | } 91 | -------------------------------------------------------------------------------- /cmd/daemon/session-init.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/curusarn/resh/internal/recordint" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | type sessionInitHandler struct { 13 | sugar *zap.SugaredLogger 14 | subscribers []chan recordint.SessionInit 15 | } 16 | 17 | func (h *sessionInitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 18 | sugar := h.sugar.With(zap.String("endpoint", "/session_init")) 19 | sugar.Debugw("Handling request, sending response, reading body ...") 20 | w.Write([]byte("OK\n")) 21 | // TODO: should we somehow check for errors here? 22 | jsn, err := io.ReadAll(r.Body) 23 | // run rest of the handler as goroutine to prevent any hangups 24 | go func() { 25 | if err != nil { 26 | sugar.Errorw("Error reading body", "error", err) 27 | return 28 | } 29 | 30 | sugar.Debugw("Unmarshaling record ...") 31 | rec := recordint.SessionInit{} 32 | err = json.Unmarshal(jsn, &rec) 33 | if err != nil { 34 | sugar.Errorw("Error during unmarshaling", 35 | "error", err, 36 | "payload", jsn, 37 | ) 38 | return 39 | } 40 | sugar := sugar.With( 41 | "sessionID", rec.SessionID, 42 | "sessionPID", rec.SessionPID, 43 | ) 44 | sugar.Infow("Got session, sending to subscribers ...") 45 | for _, sub := range h.subscribers { 46 | sub <- rec 47 | } 48 | sugar.Debugw("Session sent to subscribers") 49 | }() 50 | } 51 | -------------------------------------------------------------------------------- /cmd/daemon/status.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/curusarn/resh/internal/msg" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | type statusHandler struct { 12 | sugar *zap.SugaredLogger 13 | } 14 | 15 | func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 16 | sugar := h.sugar.With(zap.String("endpoint", "/status")) 17 | sugar.Debugw("Handling request ...") 18 | resp := msg.StatusResponse{ 19 | Status: true, 20 | Version: version, 21 | Commit: commit, 22 | } 23 | jsn, err := json.Marshal(&resp) 24 | if err != nil { 25 | sugar.Errorw("Error when marshaling", 26 | "error", err, 27 | "response", resp, 28 | ) 29 | return 30 | } 31 | w.Write(jsn) 32 | sugar.Infow("Request handled") 33 | } 34 | -------------------------------------------------------------------------------- /cmd/generate-uuid/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // Small utility to generate UUID's using google/uuid golang package 11 | // Doesn't check arguments 12 | // Exits with status 1 on error 13 | func main() { 14 | rnd, err := uuid.NewRandom() 15 | if err != nil { 16 | fmt.Fprintf(os.Stderr, "ERROR: could not get new random source: %v", err) 17 | os.Exit(1) 18 | } 19 | id := rnd.String() 20 | if id == "" { 21 | fmt.Fprintf(os.Stderr, "ERROR: got invalid UUID from package") 22 | os.Exit(1) 23 | } 24 | // No newline 25 | fmt.Print(id) 26 | } 27 | -------------------------------------------------------------------------------- /cmd/get-epochtime/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/curusarn/resh/internal/epochtime" 7 | ) 8 | 9 | // Small utility to get epochtime in millisecond precision 10 | // Doesn't check arguments 11 | // Exits with status 1 on error 12 | func main() { 13 | fmt.Printf("%s", epochtime.Now()) 14 | } 15 | -------------------------------------------------------------------------------- /cmd/install-utils/device.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/curusarn/resh/internal/datadir" 8 | "github.com/curusarn/resh/internal/device" 9 | "github.com/curusarn/resh/internal/output" 10 | ) 11 | 12 | func setupDevice(out *output.Output) { 13 | dataDir, err := datadir.MakePath() 14 | if err != nil { 15 | fmt.Fprintf(os.Stderr, "ERROR: Failed to get/setup data directory: %v\n", err) 16 | os.Exit(1) 17 | } 18 | err = device.SetupName(out, dataDir) 19 | if err != nil { 20 | fmt.Fprintf(os.Stderr, "ERROR: Failed to check/setup device name: %v\n", err) 21 | os.Exit(1) 22 | } 23 | err = device.SetupID(dataDir) 24 | if err != nil { 25 | fmt.Fprintf(os.Stderr, "ERROR: Failed to check/setup device ID: %v\n", err) 26 | os.Exit(1) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /cmd/install-utils/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/curusarn/resh/internal/cfg" 8 | "github.com/curusarn/resh/internal/logger" 9 | "github.com/curusarn/resh/internal/output" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | // info passed during build 14 | var version string 15 | var commit string 16 | var development string 17 | 18 | func main() { 19 | config, errCfg := cfg.New() 20 | logger, err := logger.New("install-utils", config.LogLevel, development) 21 | if err != nil { 22 | fmt.Printf("Error while creating logger: %v", err) 23 | } 24 | defer logger.Sync() // flushes buffer, if any 25 | if errCfg != nil { 26 | logger.Error("Error while getting configuration", zap.Error(errCfg)) 27 | } 28 | sugar := logger.Sugar() 29 | sugar.Infow("Install-utils invoked ...", 30 | "version", version, 31 | "commit", commit, 32 | ) 33 | out := output.New(logger, "install-utils ERROR") 34 | 35 | if len(os.Args) < 2 { 36 | out.Error("ERROR: Not enough arguments\n") 37 | printUsage(os.Stderr) 38 | os.Exit(1) 39 | } 40 | command := os.Args[1] 41 | switch command { 42 | case "setup-device": 43 | setupDevice(out) 44 | case "migrate-all": 45 | migrateAll(out) 46 | case "help": 47 | printUsage(os.Stdout) 48 | default: 49 | out.Error(fmt.Sprintf("ERROR: Unknown command: %s\n", command)) 50 | printUsage(os.Stderr) 51 | os.Exit(1) 52 | } 53 | } 54 | 55 | func printUsage(f *os.File) { 56 | usage := ` 57 | USAGE: ./install-utils COMMAND 58 | Utils used during RESH installation. 59 | 60 | COMMANDS: 61 | setup-device setup device name and device ID 62 | migrate-all update config and history to latest format 63 | help show this help 64 | 65 | ` 66 | fmt.Fprint(f, usage) 67 | } 68 | -------------------------------------------------------------------------------- /cmd/install-utils/migrate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | 8 | "github.com/curusarn/resh/internal/cfg" 9 | "github.com/curusarn/resh/internal/datadir" 10 | "github.com/curusarn/resh/internal/futil" 11 | "github.com/curusarn/resh/internal/output" 12 | "github.com/curusarn/resh/internal/recio" 13 | ) 14 | 15 | func printRecoveryInfo(rf *futil.RestorableFile) { 16 | fmt.Printf(" -> Backup is '%s'\n"+ 17 | " -> Original file location is '%s'\n"+ 18 | " -> Please copy the backup over the file - run: cp -f '%s' '%s'\n\n", 19 | rf.PathBackup, rf.Path, 20 | rf.PathBackup, rf.Path, 21 | ) 22 | } 23 | 24 | func migrateAll(out *output.Output) { 25 | cfgBackup, err := migrateConfig(out) 26 | if err != nil { 27 | // out.InfoE("Failed to update config file format", err) 28 | out.FatalE("Failed to update config file format", err) 29 | } 30 | err = migrateHistory(out) 31 | if err != nil { 32 | errHist := err 33 | out.InfoE("Failed to update RESH history", errHist) 34 | out.Info("Restoring config from backup ...") 35 | err = cfgBackup.Restore() 36 | if err != nil { 37 | out.InfoE("FAILED TO RESTORE CONFIG FROM BACKUP!", err) 38 | printRecoveryInfo(cfgBackup) 39 | } else { 40 | out.Info("Config file was restored successfully") 41 | } 42 | out.FatalE("Failed to update history", errHist) 43 | } 44 | } 45 | 46 | func migrateConfig(out *output.Output) (*futil.RestorableFile, error) { 47 | cfgPath, err := cfg.GetPath() 48 | if err != nil { 49 | return nil, fmt.Errorf("could not get config file path: %w", err) 50 | } 51 | 52 | // Touch config to get rid of edge-cases 53 | created, err := futil.TouchFile(cfgPath) 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to touch config file: %w", err) 56 | } 57 | 58 | // Backup 59 | backup, err := futil.BackupFile(cfgPath) 60 | if err != nil { 61 | return nil, fmt.Errorf("could not backup config file: %w", err) 62 | } 63 | 64 | // Migrate 65 | changes, err := cfg.Migrate() 66 | if err != nil { 67 | // Restore 68 | errMigrate := err 69 | errMigrateWrap := fmt.Errorf("failed to update config file: %w", errMigrate) 70 | out.InfoE("Failed to update config file format", errMigrate) 71 | out.Info("Restoring config from backup ...") 72 | err = backup.Restore() 73 | if err != nil { 74 | out.InfoE("FAILED TO RESTORE CONFIG FROM BACKUP!", err) 75 | printRecoveryInfo(backup) 76 | } else { 77 | out.Info("Config file was restored successfully") 78 | } 79 | // We are returning the root cause - there might be a better solution how to report the errors 80 | return nil, errMigrateWrap 81 | } 82 | if created { 83 | out.Info(fmt.Sprintf("RESH config created in '%s'", cfgPath)) 84 | } else if changes { 85 | out.Info("RESH config file format has changed since last update - your config was updated to reflect the changes.") 86 | } 87 | return backup, nil 88 | } 89 | 90 | func migrateHistory(out *output.Output) error { 91 | err := migrateHistoryLocation(out) 92 | if err != nil { 93 | return fmt.Errorf("failed to move history to new location %w", err) 94 | } 95 | return migrateHistoryFormat(out) 96 | } 97 | 98 | // Find first existing history and use it 99 | // Don't bother with merging of history in multiple locations - it could get messy and it shouldn't be necessary 100 | func migrateHistoryLocation(out *output.Output) error { 101 | dataDir, err := datadir.MakePath() 102 | if err != nil { 103 | return fmt.Errorf("failed to get data directory: %w", err) 104 | } 105 | historyPath := path.Join(dataDir, datadir.HistoryFileName) 106 | 107 | exists, err := futil.FileExists(historyPath) 108 | if err != nil { 109 | return fmt.Errorf("failed to check history file: %w", err) 110 | } 111 | if exists { 112 | // TODO: get rid of this output (later) 113 | out.Info(fmt.Sprintf("Found history file in '%s' - nothing to move", historyPath)) 114 | return nil 115 | } 116 | 117 | homeDir, err := os.UserHomeDir() 118 | if err != nil { 119 | return fmt.Errorf("failed to get user home directory: %w", err) 120 | } 121 | 122 | legacyHistoryPaths := []string{ 123 | path.Join(homeDir, ".resh_history.json"), 124 | path.Join(homeDir, ".resh/history.json"), 125 | } 126 | for _, path := range legacyHistoryPaths { 127 | exists, err = futil.FileExists(path) 128 | if err != nil { 129 | return fmt.Errorf("failed to check existence of legacy history file: %w", err) 130 | } 131 | if exists { 132 | // TODO: maybe get rid of this output later 133 | out.Info(fmt.Sprintf("Copying history file to new location: '%s' -> '%s' ...", path, historyPath)) 134 | err = futil.CopyFile(path, historyPath) 135 | if err != nil { 136 | return fmt.Errorf("failed to copy history file: %w", err) 137 | } 138 | out.Info("History file copied successfully") 139 | return nil 140 | } 141 | } 142 | // out.Info("WARNING: No RESH history file found (this is normal during new installation)") 143 | return nil 144 | } 145 | 146 | func migrateHistoryFormat(out *output.Output) error { 147 | dataDir, err := datadir.MakePath() 148 | if err != nil { 149 | return fmt.Errorf("could not get user data directory: %w", err) 150 | } 151 | historyPath := path.Join(dataDir, datadir.HistoryFileName) 152 | 153 | exists, err := futil.FileExists(historyPath) 154 | if err != nil { 155 | return fmt.Errorf("failed to check existence of history file: %w", err) 156 | } 157 | if !exists { 158 | out.Error("There is no RESH history file - this is normal if you are installing RESH for the first time on this device") 159 | _, err = futil.TouchFile(historyPath) 160 | if err != nil { 161 | return fmt.Errorf("failed to touch history file: %w", err) 162 | } 163 | return nil 164 | } 165 | 166 | backup, err := futil.BackupFile(historyPath) 167 | if err != nil { 168 | return fmt.Errorf("could not back up history file: %w", err) 169 | } 170 | 171 | rio := recio.New(out.Logger.Sugar()) 172 | 173 | recs, err := rio.ReadAndFixFile(historyPath, 3) 174 | if err != nil { 175 | return fmt.Errorf("could not load history file: %w", err) 176 | } 177 | err = rio.OverwriteFile(historyPath, recs) 178 | if err != nil { 179 | // Restore 180 | errMigrate := err 181 | errMigrateWrap := fmt.Errorf("failed to update format of history file: %w", errMigrate) 182 | out.InfoE("Failed to update RESH history file format", errMigrate) 183 | out.Info("Restoring RESH history from backup ...") 184 | err = backup.Restore() 185 | if err != nil { 186 | out.InfoE("FAILED TO RESTORE RESH HISTORY FROM BACKUP!", err) 187 | printRecoveryInfo(backup) 188 | } else { 189 | out.Info("RESH history file was restored successfully") 190 | } 191 | // We are returning the root cause - there might be a better solution how to report the errors 192 | return errMigrateWrap 193 | } 194 | return nil 195 | } 196 | -------------------------------------------------------------------------------- /cmd/postcollect/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/curusarn/resh/internal/cfg" 8 | "github.com/curusarn/resh/internal/collect" 9 | "github.com/curusarn/resh/internal/logger" 10 | "github.com/curusarn/resh/internal/opt" 11 | "github.com/curusarn/resh/internal/output" 12 | "github.com/curusarn/resh/internal/recordint" 13 | "github.com/curusarn/resh/record" 14 | "github.com/spf13/pflag" 15 | "go.uber.org/zap" 16 | 17 | "strconv" 18 | ) 19 | 20 | // info passed during build 21 | var version string 22 | var commit string 23 | var development string 24 | 25 | func main() { 26 | config, errCfg := cfg.New() 27 | logger, err := logger.New("postcollect", config.LogLevel, development) 28 | if err != nil { 29 | fmt.Printf("Error while creating logger: %v", err) 30 | } 31 | defer logger.Sync() // flushes buffer, if any 32 | if errCfg != nil { 33 | logger.Error("Error while getting configuration", zap.Error(errCfg)) 34 | } 35 | out := output.New(logger, "resh-postcollect ERROR") 36 | 37 | args := opt.HandleVersionOpts(out, os.Args, version, commit) 38 | 39 | flags := pflag.NewFlagSet("", pflag.ExitOnError) 40 | exitCode := flags.Int("exit-code", -1, "Exit code") 41 | sessionID := flags.String("session-id", "", "RESH generated session ID") 42 | recordID := flags.String("record-id", "", "RESH generated record ID") 43 | shlvl := flags.Int("shlvl", -1, "$SHLVL") 44 | rtb := flags.String("time-before", "-1", "Before $EPOCHREALTIME") 45 | rta := flags.String("time-after", "-1", "After $EPOCHREALTIME") 46 | flags.Parse(args) 47 | 48 | timeAfter, err := strconv.ParseFloat(*rta, 64) 49 | if err != nil { 50 | out.FatalE("Error while parsing flag --time-after", err) 51 | } 52 | timeBefore, err := strconv.ParseFloat(*rtb, 64) 53 | if err != nil { 54 | out.FatalE("Error while parsing flag --time-before", err) 55 | } 56 | duration := timeAfter - timeBefore 57 | 58 | // FIXME: use recordint.Postcollect 59 | rec := recordint.Collect{ 60 | SessionID: *sessionID, 61 | Shlvl: *shlvl, 62 | 63 | Rec: record.V1{ 64 | RecordID: *recordID, 65 | SessionID: *sessionID, 66 | 67 | ExitCode: *exitCode, 68 | Duration: fmt.Sprintf("%.4f", duration), 69 | 70 | PartsNotMerged: true, 71 | }, 72 | } 73 | collect.SendRecord(out, rec, strconv.Itoa(config.Port), "/record") 74 | } 75 | -------------------------------------------------------------------------------- /cmd/session-init/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/curusarn/resh/internal/cfg" 8 | "github.com/curusarn/resh/internal/collect" 9 | "github.com/curusarn/resh/internal/logger" 10 | "github.com/curusarn/resh/internal/opt" 11 | "github.com/curusarn/resh/internal/output" 12 | "github.com/curusarn/resh/internal/recordint" 13 | "github.com/spf13/pflag" 14 | "go.uber.org/zap" 15 | 16 | "strconv" 17 | ) 18 | 19 | // info passed during build 20 | var version string 21 | var commit string 22 | var development string 23 | 24 | func main() { 25 | config, errCfg := cfg.New() 26 | logger, err := logger.New("session-init", config.LogLevel, development) 27 | if err != nil { 28 | fmt.Printf("Error while creating logger: %v", err) 29 | } 30 | defer logger.Sync() // flushes buffer, if any 31 | if errCfg != nil { 32 | logger.Error("Error while getting configuration", zap.Error(errCfg)) 33 | } 34 | out := output.New(logger, "resh-collect ERROR") 35 | 36 | args := opt.HandleVersionOpts(out, os.Args, version, commit) 37 | 38 | flags := pflag.NewFlagSet("", pflag.ExitOnError) 39 | sessionID := flags.String("session-id", "", "RESH generated session ID") 40 | sessionPID := flags.Int("session-pid", -1, "$$ - Shell session PID") 41 | flags.Parse(args) 42 | 43 | rec := recordint.SessionInit{ 44 | SessionID: *sessionID, 45 | SessionPID: *sessionPID, 46 | } 47 | collect.SendSessionInit(out, rec, strconv.Itoa(config.Port)) 48 | } 49 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/curusarn/resh 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.2.1 7 | github.com/awesome-gocui/gocui v1.1.0 8 | github.com/google/uuid v1.3.0 9 | github.com/mattn/go-isatty v0.0.17 10 | github.com/mitchellh/go-ps v1.0.0 11 | github.com/spf13/cobra v1.6.1 12 | github.com/spf13/pflag v1.0.5 13 | github.com/whilp/git-urls v1.0.0 14 | go.uber.org/zap v1.24.0 15 | golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 16 | ) 17 | 18 | require ( 19 | github.com/gdamore/encoding v1.0.0 // indirect 20 | github.com/gdamore/tcell/v2 v2.6.0 // indirect 21 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 22 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 23 | github.com/mattn/go-runewidth v0.0.14 // indirect 24 | github.com/rivo/uniseg v0.4.4 // indirect 25 | go.uber.org/atomic v1.10.0 // indirect 26 | go.uber.org/multierr v1.9.0 // indirect 27 | golang.org/x/sys v0.5.0 // indirect 28 | golang.org/x/term v0.5.0 // indirect 29 | golang.org/x/text v0.7.0 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /img/graph-command-sequences.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curusarn/resh/a6e4c85d31bb5080c0783299e3636dc3948d6963/img/graph-command-sequences.png -------------------------------------------------------------------------------- /img/screen-recall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curusarn/resh/a6e4c85d31bb5080c0783299e3636dc3948d6963/img/screen-recall.png -------------------------------------------------------------------------------- /img/screen-resh-cli-v2-6-no-query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curusarn/resh/a6e4c85d31bb5080c0783299e3636dc3948d6963/img/screen-resh-cli-v2-6-no-query.png -------------------------------------------------------------------------------- /img/screen-resh-cli-v2-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curusarn/resh/a6e4c85d31bb5080c0783299e3636dc3948d6963/img/screen-resh-cli-v2-6.png -------------------------------------------------------------------------------- /img/screen-resh-cli-v2-7-init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curusarn/resh/a6e4c85d31bb5080c0783299e3636dc3948d6963/img/screen-resh-cli-v2-7-init.png -------------------------------------------------------------------------------- /img/screen-resh-cli-v2-7-no-query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curusarn/resh/a6e4c85d31bb5080c0783299e3636dc3948d6963/img/screen-resh-cli-v2-7-no-query.png -------------------------------------------------------------------------------- /img/screen-resh-cli-v2-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curusarn/resh/a6e4c85d31bb5080c0783299e3636dc3948d6963/img/screen-resh-cli-v2-7.png -------------------------------------------------------------------------------- /img/screen-resh-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curusarn/resh/a6e4c85d31bb5080c0783299e3636dc3948d6963/img/screen-resh-cli.png -------------------------------------------------------------------------------- /img/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curusarn/resh/a6e4c85d31bb5080c0783299e3636dc3948d6963/img/screen.png -------------------------------------------------------------------------------- /installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## One command installation 4 | 5 | Feel free to check the `rawinstall.sh` script before running it. 6 | 7 | ```sh 8 | curl -fsSL https://raw.githubusercontent.com/curusarn/resh/master/scripts/rawinstall.sh | bash 9 | ``` 10 | 11 | ℹ️ You will need to have `bash`, `curl`, and `tar` installed. 12 | 13 | ## Clone & install 14 | 15 | ```sh 16 | git clone https://github.com/curusarn/resh.git 17 | cd resh 18 | scripts/rawinstall.sh 19 | ``` 20 | 21 | ## Build from source 22 | 23 | :warning: Building from source is intended for development and troubleshooting. 24 | 25 | ```sh 26 | git clone https://github.com/curusarn/resh.git 27 | cd resh 28 | make install 29 | ``` 30 | 31 | ## Update 32 | 33 | Once installed RESH can be updated using: 34 | ```sh 35 | reshctl update 36 | ``` 37 | 38 | ## Disabling RESH 39 | 40 | If you have a persistent issue with RESH you can temporarily disable it and then enable it later. 41 | 42 | ℹ️ You won't lose your history nor configuration. 43 | 44 | Go to `~/.zshrc` and `~/.bashrc` and comment out following lines: 45 | ```sh 46 | [[ -f ~/.resh/shellrc ]] && source ~/.resh/shellrc 47 | [[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh # bashrc only 48 | ``` 49 | The second line is bash-specific so you won't find it in `~/.zshrc` 50 | 51 | You can re-enable RESH by uncommenting the lines above or by re-installing it. 52 | 53 | ## Uninstallation 54 | 55 | You can uninstall RESH by running: `rm -rf ~/.resh/`. 56 | 57 | ⚠️ Restart all open terminals after uninstall! 58 | 59 | ### Installed files 60 | 61 | Binaries and shell files are in `~/.resh/`. 62 | 63 | Recorded history, device files, and logs are in one of: 64 | - `~/.local/share/resh/` 65 | - `$XDG_DATA_HOME/resh/` (if set) 66 | 67 | RESH config file is read from one of: 68 | - `~/.config/resh.toml` 69 | - `$XDG_CONFIG_HOME/resh.toml` (if set) 70 | 71 | RESH also adds a following lines to `~/.zshrc` and `~/.bashrc` to load itself on terminal startup: 72 | ```sh 73 | [[ -f ~/.resh/shellrc ]] && source ~/.resh/shellrc 74 | [[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh # bashrc only 75 | ``` 76 | 77 | :information_source: RESH follows [XDG directory specification ⇗](https://maex.me/2019/12/the-power-of-the-xdg-base-directory-specification/) 78 | 79 | #### Backup files 80 | 81 | During update **config** and **history** files are backed up to: 82 | 83 | - `~/.config/resh.toml.backup-` 84 | - `~/.local/share/resh/history.reshjson.backup-` 85 | - `$XDG_DATA_HOME/resh/history.reshjson.backup-` (if set) 86 | 87 | Backups allow safe rollbacks during or after installation. 88 | They are not deleted automatically. You can delete them with: 89 | 90 | ```shell 91 | rm ~/.config/resh.toml.backup-* 92 | rm ${XDG_DATA_HOME-~/.local/share}/resh/history.reshjson.backup-* 93 | ``` -------------------------------------------------------------------------------- /internal/cfg/cfg.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | 8 | "github.com/BurntSushi/toml" 9 | "go.uber.org/zap" 10 | "go.uber.org/zap/zapcore" 11 | ) 12 | 13 | // configFile used to parse the config file 14 | type configFile struct { 15 | // ConfigVersion - never remove this 16 | ConfigVersion *string 17 | 18 | // added in legacy 19 | Port *int 20 | SesswatchPeriodSeconds *uint 21 | SesshistInitHistorySize *int 22 | BindControlR *bool 23 | Debug *bool 24 | 25 | // added in v1 26 | LogLevel *string 27 | 28 | // added in legacy 29 | // deprecated in v1 30 | BindArrowKeysBash *bool 31 | BindArrowKeysZsh *bool 32 | } 33 | 34 | // Config returned by this package to be used in the rest of the project 35 | type Config struct { 36 | // Port used by daemon and rest of the components to communicate 37 | // Make sure to restart the daemon when you change it 38 | Port int 39 | 40 | // BindControlR causes CTRL+R to launch the search app 41 | BindControlR bool 42 | // LogLevel used to filter logs 43 | LogLevel zapcore.Level 44 | 45 | // Debug mode for search app 46 | Debug bool 47 | // SessionWatchPeriodSeconds is how often should daemon check if terminal 48 | // sessions are still alive 49 | // There is not much need to adjust the value because both memory overhead of watched sessions 50 | // and the CPU overhead of checking them are quite low 51 | SessionWatchPeriodSeconds uint 52 | // ReshHistoryMinSize is how large resh history needs to be for 53 | // daemon to ignore standard shell history files 54 | // Ignoring standard shell history gives us more consistent experience 55 | // but you can increase this to something large to see standard shell history in RESH search 56 | ReshHistoryMinSize int 57 | } 58 | 59 | // defaults for config 60 | var defaults = Config{ 61 | Port: 2627, 62 | LogLevel: zap.InfoLevel, 63 | BindControlR: true, 64 | 65 | Debug: false, 66 | SessionWatchPeriodSeconds: 600, 67 | ReshHistoryMinSize: 1000, 68 | } 69 | 70 | const headerComment = `## 71 | ###################### 72 | ## RESH config (v1) ## 73 | ###################### 74 | ## Here you can find info about RESH configuration options. 75 | ## You can uncomment the options and customize them. 76 | 77 | ## Required. 78 | ## The config format can change in future versions. 79 | ## ConfigVersion helps us seamlessly upgrade to the new formats. 80 | # ConfigVersion = "v1" 81 | 82 | ## Port used by RESH daemon and rest of the components to communicate. 83 | ## Make sure to restart the daemon (pkill resh-daemon) when you change it. 84 | # Port = 2627 85 | 86 | ## Controls how much and how detailed logs all RESH components produce. 87 | ## Use "debug" for full logs when you encounter an issue 88 | ## Options: "debug", "info", "warn", "error", "fatal" 89 | # LogLevel = "info" 90 | 91 | ## When BindControlR is "true" RESH search app is bound to CTRL+R on terminal startup 92 | # BindControlR = true 93 | 94 | ## When Debug is "true" the RESH search app runs in debug mode. 95 | ## This is useful for development. 96 | # Debug = false 97 | 98 | ## Daemon keeps track of running terminal sessions. 99 | ## SessionWatchPeriodSeconds controls how often daemon checks if the sessions are still alive. 100 | ## You shouldn't need to adjust this. 101 | # SessionWatchPeriodSeconds = 600 102 | 103 | ## When RESH is first installed there is no RESH history so there is nothing to search. 104 | ## As a temporary workaround, RESH daemon parses bash/zsh shell history and searches it. 105 | ## Once RESH history is big enough RESH stops using bash/zsh history. 106 | ## ReshHistoryMinSize controls how big RESH history needs to be before this happens. 107 | ## You can increase this this to e.g. 10000 to get RESH to use bash/zsh history longer. 108 | # ReshHistoryMinSize = 1000 109 | 110 | ` 111 | 112 | func getConfigPath() (string, error) { 113 | fname := "resh.toml" 114 | xdgDir, found := os.LookupEnv("XDG_CONFIG_HOME") 115 | if found { 116 | return path.Join(xdgDir, fname), nil 117 | } 118 | homeDir, err := os.UserHomeDir() 119 | if err != nil { 120 | return "", fmt.Errorf("could not get user home dir: %w", err) 121 | } 122 | return path.Join(homeDir, ".config", fname), nil 123 | } 124 | 125 | func readConfig(path string) (*configFile, error) { 126 | var config configFile 127 | if _, err := toml.DecodeFile(path, &config); err != nil { 128 | return &config, fmt.Errorf("could not decode config: %w", err) 129 | } 130 | return &config, nil 131 | } 132 | 133 | func getConfig() (*configFile, error) { 134 | path, err := getConfigPath() 135 | if err != nil { 136 | return nil, fmt.Errorf("could not get config file path: %w", err) 137 | } 138 | return readConfig(path) 139 | } 140 | 141 | // returned config is always usable, returned errors are informative 142 | func processAndFillDefaults(configF *configFile) (Config, error) { 143 | config := defaults 144 | 145 | if configF.Port != nil { 146 | config.Port = *configF.Port 147 | } 148 | if configF.SesswatchPeriodSeconds != nil { 149 | config.SessionWatchPeriodSeconds = *configF.SesswatchPeriodSeconds 150 | } 151 | if configF.SesshistInitHistorySize != nil { 152 | config.ReshHistoryMinSize = *configF.SesshistInitHistorySize 153 | } 154 | 155 | var err error 156 | if configF.LogLevel != nil { 157 | logLevel, err := zapcore.ParseLevel(*configF.LogLevel) 158 | if err != nil { 159 | err = fmt.Errorf("could not parse log level: %w", err) 160 | } else { 161 | config.LogLevel = logLevel 162 | } 163 | } 164 | 165 | if configF.BindControlR != nil { 166 | config.BindControlR = *configF.BindControlR 167 | } 168 | 169 | return config, err 170 | } 171 | 172 | // New returns a config file 173 | // returned config is always usable, returned errors are informative 174 | func New() (Config, error) { 175 | configF, err := getConfig() 176 | if err != nil { 177 | return defaults, fmt.Errorf("using default config because of error while getting/reading config: %w", err) 178 | } 179 | 180 | config, err := processAndFillDefaults(configF) 181 | if err != nil { 182 | return config, fmt.Errorf("errors while processing config: %w", err) 183 | } 184 | return config, nil 185 | } 186 | 187 | // GetPath returns path to config 188 | // Shouldn't be necessary for basic use 189 | func GetPath() (string, error) { 190 | return getConfigPath() 191 | } 192 | -------------------------------------------------------------------------------- /internal/cfg/migrate.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/BurntSushi/toml" 8 | ) 9 | 10 | // Migrate old config versions to current config version 11 | // returns true if any changes were made to the config 12 | func Migrate() (bool, error) { 13 | fpath, err := getConfigPath() 14 | if err != nil { 15 | return false, fmt.Errorf("could not get config file path: %w", err) 16 | } 17 | configF, err := readConfig(fpath) 18 | if err != nil { 19 | return false, fmt.Errorf("could not read config: %w", err) 20 | } 21 | const current = "v1" 22 | if configF.ConfigVersion != nil && *configF.ConfigVersion == current { 23 | return false, nil 24 | } 25 | 26 | if configF.ConfigVersion == nil { 27 | configF, err = legacyToV1(configF) 28 | if err != nil { 29 | return true, fmt.Errorf("error converting config from version 'legacy' to 'v1': %w", err) 30 | } 31 | } 32 | 33 | if *configF.ConfigVersion != current { 34 | return false, fmt.Errorf("unrecognized config version: '%s'", *configF.ConfigVersion) 35 | } 36 | err = writeConfig(configF, fpath) 37 | if err != nil { 38 | return true, fmt.Errorf("could not write migrated config: %w", err) 39 | } 40 | return true, nil 41 | } 42 | 43 | // writeConfig should only be used when migrating config to new version 44 | // writing the config file discards all comments in the config file (limitation of TOML library) 45 | // to make up for lost comments we add header comment to the start of the file 46 | func writeConfig(config *configFile, path string) error { 47 | file, err := os.OpenFile(path, os.O_RDWR|os.O_TRUNC, 0666) 48 | if err != nil { 49 | return fmt.Errorf("could not open config for writing: %w", err) 50 | } 51 | defer file.Close() 52 | _, err = file.WriteString(headerComment) 53 | if err != nil { 54 | return fmt.Errorf("could not write config header: %w", err) 55 | } 56 | err = toml.NewEncoder(file).Encode(config) 57 | if err != nil { 58 | return fmt.Errorf("could not encode config: %w", err) 59 | } 60 | return nil 61 | } 62 | 63 | func legacyToV1(config *configFile) (*configFile, error) { 64 | if config.ConfigVersion != nil { 65 | return nil, fmt.Errorf("config version is not 'legacy': '%s'", *config.ConfigVersion) 66 | } 67 | version := "v1" 68 | newConf := configFile{ 69 | ConfigVersion: &version, 70 | } 71 | // Remove defaults 72 | if config.Port != nil && *config.Port != 2627 { 73 | newConf.Port = config.Port 74 | } 75 | if config.SesswatchPeriodSeconds != nil && *config.SesswatchPeriodSeconds != 120 { 76 | newConf.SesswatchPeriodSeconds = config.SesswatchPeriodSeconds 77 | } 78 | if config.SesshistInitHistorySize != nil && *config.SesshistInitHistorySize != 1000 { 79 | newConf.SesshistInitHistorySize = config.SesshistInitHistorySize 80 | } 81 | if config.BindControlR != nil && *config.BindControlR != true { 82 | newConf.BindControlR = config.BindControlR 83 | } 84 | if config.Debug != nil && *config.Debug != false { 85 | newConf.Debug = config.Debug 86 | } 87 | return &newConf, nil 88 | } 89 | 90 | // func v1ToV2(config *configFile) (*configFile, error) { 91 | // if *config.ConfigVersion != "v1" { 92 | // return nil, fmt.Errorf("config version is not 'legacy': '%s'", *config.ConfigVersion) 93 | // } 94 | // version := "v2" 95 | // newConf := configFile{ 96 | // ConfigVersion: &version, 97 | // // Here goes all config fields - no need to prune defaults like we do for legacy 98 | // } 99 | // return &newConf, nil 100 | // } 101 | -------------------------------------------------------------------------------- /internal/check/check.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | func LoginShell() (string, error) { 12 | shellPath, found := os.LookupEnv("SHELL") 13 | if !found { 14 | return "", fmt.Errorf("env variable $SHELL is not set") 15 | } 16 | parts := strings.Split(shellPath, "/") 17 | shell := parts[len(parts)-1] 18 | if shell != "bash" && shell != "zsh" { 19 | return fmt.Sprintf("Current shell (%s) is unsupported\n", shell), nil 20 | } 21 | return "", nil 22 | } 23 | 24 | func msgShellVersion(shell, expectedVer, actualVer string) string { 25 | return fmt.Sprintf( 26 | "Minimal supported %s version is %s. You have %s.\n"+ 27 | " -> Update to %s %s+ if you want to use RESH with it", 28 | shell, expectedVer, actualVer, 29 | shell, expectedVer, 30 | ) 31 | } 32 | 33 | func BashVersion() (string, error) { 34 | out, err := exec.Command("bash", "-c", "echo $BASH_VERSION").Output() 35 | if err != nil { 36 | return "", fmt.Errorf("command failed: %w", err) 37 | } 38 | verStr := strings.TrimSuffix(string(out), "\n") 39 | ver, err := parseVersion(verStr) 40 | if err != nil { 41 | if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 127 { 42 | return "Bash not installed", nil 43 | } 44 | return "", fmt.Errorf("failed to parse version: %w", err) 45 | } 46 | 47 | if ver.Major < 4 || (ver.Major == 4 && ver.Minor < 3) { 48 | return msgShellVersion("bash", "4.3", verStr), nil 49 | } 50 | return "", nil 51 | } 52 | 53 | func ZshVersion() (string, error) { 54 | out, err := exec.Command("zsh", "-c", "echo $ZSH_VERSION").Output() 55 | if err != nil { 56 | return "", fmt.Errorf("command failed: %w", err) 57 | } 58 | verStr := strings.TrimSuffix(string(out), "\n") 59 | ver, err := parseVersion(string(out)) 60 | if err != nil { 61 | if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 127 { 62 | return "Zsh not installed", nil 63 | } 64 | return "", fmt.Errorf("failed to parse version: %w", err) 65 | } 66 | 67 | if ver.Major < 5 { 68 | return msgShellVersion("zsh", "5.0", verStr), nil 69 | } 70 | return "", nil 71 | } 72 | 73 | type version struct { 74 | Major int 75 | Minor int 76 | Rest string 77 | } 78 | 79 | func parseVersion(str string) (version, error) { 80 | parts := strings.SplitN(str, ".", 3) 81 | if len(parts) < 3 { 82 | return version{}, fmt.Errorf("not enough parts") 83 | } 84 | major, err := strconv.Atoi(parts[0]) 85 | if err != nil { 86 | return version{}, fmt.Errorf("failed to parse major version: %w", err) 87 | } 88 | minor, err := strconv.Atoi(parts[1]) 89 | if err != nil { 90 | return version{}, fmt.Errorf("failed to parse minor version: %w", err) 91 | } 92 | ver := version{ 93 | Major: major, 94 | Minor: minor, 95 | Rest: parts[2], 96 | } 97 | return ver, nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/collect/collect.go: -------------------------------------------------------------------------------- 1 | package collect 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/curusarn/resh/internal/output" 14 | "github.com/curusarn/resh/internal/recordint" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | // SendRecord to daemon 19 | func SendRecord(out *output.Output, r recordint.Collect, port, path string) { 20 | out.Logger.Debug("Sending record ...", 21 | zap.String("cmdLine", r.Rec.CmdLine), 22 | zap.String("sessionID", r.SessionID), 23 | ) 24 | recJSON, err := json.Marshal(r) 25 | if err != nil { 26 | out.FatalE("Error while encoding record", err) 27 | } 28 | 29 | req, err := http.NewRequest("POST", "http://localhost:"+port+path, 30 | bytes.NewBuffer(recJSON)) 31 | if err != nil { 32 | out.FatalE("Error while sending record", err) 33 | } 34 | req.Header.Set("Content-Type", "application/json") 35 | 36 | client := http.Client{ 37 | Timeout: 1 * time.Second, 38 | } 39 | _, err = client.Do(req) 40 | if err != nil { 41 | out.FatalDaemonNotRunning(err) 42 | } 43 | } 44 | 45 | // SendSessionInit to daemon 46 | func SendSessionInit(out *output.Output, r recordint.SessionInit, port string) { 47 | out.Logger.Debug("Sending session init ...", 48 | zap.String("sessionID", r.SessionID), 49 | zap.Int("sessionPID", r.SessionPID), 50 | ) 51 | recJSON, err := json.Marshal(r) 52 | if err != nil { 53 | out.FatalE("Error while encoding record", err) 54 | } 55 | 56 | req, err := http.NewRequest("POST", "http://localhost:"+port+"/session_init", 57 | bytes.NewBuffer(recJSON)) 58 | if err != nil { 59 | out.FatalE("Error while sending record", err) 60 | } 61 | req.Header.Set("Content-Type", "application/json") 62 | 63 | client := http.Client{ 64 | Timeout: 1 * time.Second, 65 | } 66 | _, err = client.Do(req) 67 | if err != nil { 68 | out.FatalDaemonNotRunning(err) 69 | } 70 | } 71 | 72 | // ReadFileContent and return it as a string 73 | func ReadFileContent(logger *zap.Logger, path string) string { 74 | dat, err := ioutil.ReadFile(path) 75 | if err != nil { 76 | logger.Error("Error reading file", 77 | zap.String("filePath", path), 78 | zap.Error(err), 79 | ) 80 | return "" 81 | } 82 | return strings.TrimSuffix(string(dat), "\n") 83 | } 84 | 85 | // GetGitDirs based on result of git "cdup" command 86 | func GetGitDirs(logger *zap.Logger, cdUp string, exitCode int, pwd string) (string, string) { 87 | if exitCode != 0 { 88 | return "", "" 89 | } 90 | absPath := filepath.Clean(filepath.Join(pwd, cdUp)) 91 | realPath, err := filepath.EvalSymlinks(absPath) 92 | if err != nil { 93 | logger.Error("Error while handling git dir paths", zap.Error(err)) 94 | return "", "" 95 | } 96 | return absPath, realPath 97 | } 98 | 99 | // GetTimezoneOffsetInSeconds based on zone returned by date command 100 | func GetTimezoneOffsetInSeconds(logger *zap.Logger, zone string) float64 { 101 | // date +%z -> "+0200" 102 | hoursStr := zone[:3] 103 | minsStr := zone[3:] 104 | hours, err := strconv.Atoi(hoursStr) 105 | if err != nil { 106 | logger.Error("Error while parsing hours in timezone offset", zap.Error(err)) 107 | return -1 108 | } 109 | mins, err := strconv.Atoi(minsStr) 110 | if err != nil { 111 | logger.Error("Errot while parsing minutes in timezone offset:", zap.Error(err)) 112 | return -1 113 | } 114 | secs := ((hours * 60) + mins) * 60 115 | return float64(secs) 116 | } 117 | -------------------------------------------------------------------------------- /internal/datadir/datadir.go: -------------------------------------------------------------------------------- 1 | package datadir 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | ) 8 | 9 | // Maybe there is a better place for this constant 10 | const HistoryFileName = "history.reshjson" 11 | 12 | func GetPath() (string, error) { 13 | reshDir := "resh" 14 | xdgDir, found := os.LookupEnv("XDG_DATA_HOME") 15 | if found { 16 | return path.Join(xdgDir, reshDir), nil 17 | } 18 | homeDir, err := os.UserHomeDir() 19 | if err != nil { 20 | return "", fmt.Errorf("error while getting home dir: %w", err) 21 | } 22 | return path.Join(homeDir, ".local/share/", reshDir), nil 23 | } 24 | 25 | func MakePath() (string, error) { 26 | path, err := GetPath() 27 | if err != nil { 28 | return "", err 29 | } 30 | err = os.MkdirAll(path, 0755) 31 | // skip "exists" error 32 | if err != nil && !os.IsExist(err) { 33 | return "", fmt.Errorf("error while creating directories: %w", err) 34 | } 35 | return path, nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/device/device.go: -------------------------------------------------------------------------------- 1 | // device implements helpers that get/set device config files 2 | package device 3 | 4 | import ( 5 | "bufio" 6 | "fmt" 7 | "os" 8 | "path" 9 | "strings" 10 | 11 | "github.com/curusarn/resh/internal/futil" 12 | "github.com/curusarn/resh/internal/output" 13 | "github.com/google/uuid" 14 | isatty "github.com/mattn/go-isatty" 15 | ) 16 | 17 | const fnameID = "device-id" 18 | const fnameName = "device-name" 19 | 20 | const fpathIDLegacy = ".resh/resh-uuid" 21 | 22 | const filePerm = 0644 23 | 24 | // Getters 25 | 26 | func GetID(dataDir string) (string, error) { 27 | return readValue(dataDir, fnameID) 28 | } 29 | 30 | func GetName(dataDir string) (string, error) { 31 | return readValue(dataDir, fnameName) 32 | } 33 | 34 | // Install helpers 35 | 36 | func SetupID(dataDir string) error { 37 | return setIDIfUnset(dataDir) 38 | } 39 | 40 | func SetupName(out *output.Output, dataDir string) error { 41 | return promptAndWriteNameIfUnset(out, dataDir) 42 | } 43 | 44 | func readValue(dataDir, fname string) (string, error) { 45 | fpath := path.Join(dataDir, fname) 46 | dat, err := os.ReadFile(fpath) 47 | if err != nil { 48 | return "", fmt.Errorf("could not read file with %s: %w", fname, err) 49 | } 50 | val := strings.TrimRight(string(dat), "\n") 51 | return val, nil 52 | } 53 | 54 | func setIDIfUnset(dataDir string) error { 55 | fpath := path.Join(dataDir, fnameID) 56 | exists, err := futil.FileExists(fpath) 57 | if err != nil { 58 | return err 59 | } 60 | if exists { 61 | return nil 62 | } 63 | 64 | // Try copy device ID from legacy location 65 | homeDir, err := os.UserHomeDir() 66 | if err != nil { 67 | return fmt.Errorf("could not get user home: %w", err) 68 | } 69 | fpathLegacy := path.Join(homeDir, fpathIDLegacy) 70 | exists, err = futil.FileExists(fpath) 71 | if err != nil { 72 | return err 73 | } 74 | if exists { 75 | futil.CopyFile(fpathLegacy, fpath) 76 | if err != nil { 77 | return fmt.Errorf("could not copy device ID from legacy location: %w", err) 78 | } 79 | return nil 80 | } 81 | 82 | // Generate new device ID 83 | rnd, err := uuid.NewRandom() 84 | if err != nil { 85 | return fmt.Errorf("could not get new random source: %w", err) 86 | } 87 | id := rnd.String() 88 | if id == "" { 89 | return fmt.Errorf("got invalid UUID from package") 90 | } 91 | err = os.WriteFile(fpath, []byte(id), filePerm) 92 | if err != nil { 93 | return fmt.Errorf("could not write generated ID to file: %w", err) 94 | } 95 | return nil 96 | } 97 | 98 | func promptAndWriteNameIfUnset(out *output.Output, dataDir string) error { 99 | fpath := path.Join(dataDir, fnameName) 100 | exists, err := futil.FileExists(fpath) 101 | if err != nil { 102 | return err 103 | } 104 | if exists { 105 | return nil 106 | } 107 | 108 | name, err := promptForName(out, fpath) 109 | if err != nil { 110 | return fmt.Errorf("error while prompting for input: %w", err) 111 | } 112 | err = os.WriteFile(fpath, []byte(name), filePerm) 113 | if err != nil { 114 | return fmt.Errorf("could not write name to file: %w", err) 115 | } 116 | return nil 117 | } 118 | 119 | func promptForName(out *output.Output, fpath string) (string, error) { 120 | // This function should be only ran from install-utils with attached terminal 121 | if !isatty.IsTerminal(os.Stdout.Fd()) { 122 | return "", fmt.Errorf("output is not a terminal - write name of this device to '%s' to bypass this error", fpath) 123 | } 124 | host, err := os.Hostname() 125 | if err != nil { 126 | return "", fmt.Errorf("could not get hostname (prompt default): %w", err) 127 | } 128 | hostStub := strings.Split(host, ".")[0] 129 | reader := bufio.NewReader(os.Stdin) 130 | fmt.Printf("\nChoose a short name for this device (default: '%s'): ", hostStub) 131 | input, err := reader.ReadString('\n') 132 | name := strings.TrimRight(input, "\n") 133 | if err != nil && err.Error() != "EOF" { 134 | return "", fmt.Errorf("reader error: %w", err) 135 | } 136 | if name == "" { 137 | out.Info("Got no input - using default ...") 138 | name = hostStub 139 | } 140 | out.Info(fmt.Sprintf("Device name set to '%s'", name)) 141 | fmt.Printf("You can change the device name at any time by editing '%s' file\n", fpath) 142 | return input, nil 143 | } 144 | -------------------------------------------------------------------------------- /internal/epochtime/epochtime.go: -------------------------------------------------------------------------------- 1 | package epochtime 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | func TimeToString(t time.Time) string { 9 | return fmt.Sprintf("%.2f", float64(t.UnixMilli())/1000) 10 | } 11 | 12 | func Now() string { 13 | return TimeToString(time.Now()) 14 | } 15 | -------------------------------------------------------------------------------- /internal/epochtime/epochtime_test.go: -------------------------------------------------------------------------------- 1 | package epochtime 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestConversion(t *testing.T) { 10 | epochTime := "1672702332.64" 11 | seconds, err := strconv.ParseFloat(epochTime, 64) 12 | if err != nil { 13 | t.Fatal("Test setup failed: Failed to convert constant") 14 | } 15 | if TimeToString(time.UnixMilli(int64(seconds*1000))) != epochTime { 16 | t.Fatal("EpochTime changed during conversion") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/futil/futil.go: -------------------------------------------------------------------------------- 1 | // futil implements common file-related utilities 2 | package futil 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "os" 8 | "time" 9 | ) 10 | 11 | func CopyFile(source, dest string) error { 12 | from, err := os.Open(source) 13 | if err != nil { 14 | return err 15 | } 16 | defer from.Close() 17 | 18 | // This is equivalent to: os.OpenFile(dest, os.O_RDWR|os.O_CREATE, 0666) 19 | to, err := os.Create(dest) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | _, err = io.Copy(to, from) 25 | if err != nil { 26 | return err 27 | } 28 | return to.Close() 29 | } 30 | 31 | func FileExists(fpath string) (bool, error) { 32 | _, err := os.Stat(fpath) 33 | if err == nil { 34 | // File exists 35 | return true, nil 36 | } 37 | if os.IsNotExist(err) { 38 | // File doesn't exist 39 | return false, nil 40 | } 41 | // Any other error 42 | return false, fmt.Errorf("could not stat file: %w", err) 43 | } 44 | 45 | // TouchFile touches file 46 | // Returns true if file was created false otherwise 47 | func TouchFile(fpath string) (bool, error) { 48 | exists, err := FileExists(fpath) 49 | if err != nil { 50 | return false, err 51 | } 52 | 53 | file, err := os.OpenFile(fpath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666) 54 | if err != nil { 55 | return false, fmt.Errorf("could not open/create file: %w", err) 56 | } 57 | err = file.Close() 58 | if err != nil { 59 | return false, fmt.Errorf("could not close file: %w", err) 60 | } 61 | return !exists, nil 62 | } 63 | 64 | func getBackupPath(fpath string) string { 65 | ext := fmt.Sprintf(".backup-%d", time.Now().Unix()) 66 | return fpath + ext 67 | } 68 | 69 | // BackupFile backups file using unique suffix 70 | // Returns path to backup 71 | func BackupFile(fpath string) (*RestorableFile, error) { 72 | fpathBackup := getBackupPath(fpath) 73 | exists, err := FileExists(fpathBackup) 74 | if err != nil { 75 | return nil, err 76 | } 77 | if exists { 78 | return nil, fmt.Errorf("backup already exists in the determined path") 79 | } 80 | err = CopyFile(fpath, fpathBackup) 81 | if err != nil { 82 | return nil, fmt.Errorf("failed to copy file: %w ", err) 83 | } 84 | rf := RestorableFile{ 85 | Path: fpath, 86 | PathBackup: fpathBackup, 87 | } 88 | return &rf, nil 89 | } 90 | 91 | type RestorableFile struct { 92 | Path string 93 | PathBackup string 94 | } 95 | 96 | func (r RestorableFile) Restore() error { 97 | return restoreFileFromBackup(r.Path, r.PathBackup) 98 | } 99 | 100 | func restoreFileFromBackup(fpath, fpathBak string) error { 101 | exists, err := FileExists(fpathBak) 102 | if err != nil { 103 | return err 104 | } 105 | if !exists { 106 | return fmt.Errorf("backup not found in given path: no such file or directory: %s", fpathBak) 107 | } 108 | err = CopyFile(fpathBak, fpath) 109 | if err != nil { 110 | return fmt.Errorf("failed to copy file: %w ", err) 111 | } 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /internal/histcli/histcli.go: -------------------------------------------------------------------------------- 1 | package histcli 2 | 3 | import ( 4 | "github.com/curusarn/resh/internal/recordint" 5 | "github.com/curusarn/resh/record" 6 | "go.uber.org/zap" 7 | ) 8 | 9 | // Histcli is a dump of history preprocessed for resh cli purposes 10 | type Histcli struct { 11 | // list of records 12 | List []recordint.SearchApp 13 | 14 | sugar *zap.SugaredLogger 15 | } 16 | 17 | // New Histcli 18 | func New(sugar *zap.SugaredLogger) Histcli { 19 | return Histcli{} 20 | } 21 | 22 | // AddRecord to the histcli 23 | func (h *Histcli) AddRecord(rec *record.V1) { 24 | cli := recordint.NewSearchApp(h.sugar, rec) 25 | 26 | h.List = append(h.List, cli) 27 | } 28 | 29 | // AddCmdLine to the histcli 30 | func (h *Histcli) AddCmdLine(cmdline string) { 31 | cli := recordint.NewSearchAppFromCmdLine(cmdline) 32 | 33 | h.List = append(h.List, cli) 34 | } 35 | -------------------------------------------------------------------------------- /internal/histfile/histfile.go: -------------------------------------------------------------------------------- 1 | package histfile 2 | 3 | import ( 4 | "math" 5 | "os" 6 | "strconv" 7 | "sync" 8 | 9 | "github.com/curusarn/resh/internal/histcli" 10 | "github.com/curusarn/resh/internal/histlist" 11 | "github.com/curusarn/resh/internal/recio" 12 | "github.com/curusarn/resh/internal/recordint" 13 | "github.com/curusarn/resh/internal/records" 14 | "github.com/curusarn/resh/internal/recutil" 15 | "github.com/curusarn/resh/record" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | // TODO: get rid of histfile - use histio instead 20 | // Histfile writes records to histfile 21 | type Histfile struct { 22 | sugar *zap.SugaredLogger 23 | 24 | sessionsMutex sync.Mutex 25 | sessions map[string]recordint.Collect 26 | historyPath string 27 | 28 | // NOTE: we have separate histories which only differ if there was not enough resh_history 29 | // resh_history itself is common for both bash and zsh 30 | bashCmdLines histlist.Histlist 31 | zshCmdLines histlist.Histlist 32 | 33 | cliRecords histcli.Histcli 34 | 35 | rio *recio.RecIO 36 | } 37 | 38 | // New creates new histfile and runs its goroutines 39 | func New(sugar *zap.SugaredLogger, input chan recordint.Collect, sessionsToDrop chan string, 40 | reshHistoryPath string, bashHistoryPath string, zshHistoryPath string, 41 | maxInitHistSize int, minInitHistSizeKB int, 42 | signals chan os.Signal, shutdownDone chan string) *Histfile { 43 | 44 | rio := recio.New(sugar.With("module", "histfile")) 45 | hf := Histfile{ 46 | sugar: sugar.With("module", "histfile"), 47 | sessions: map[string]recordint.Collect{}, 48 | historyPath: reshHistoryPath, 49 | bashCmdLines: histlist.New(sugar), 50 | zshCmdLines: histlist.New(sugar), 51 | cliRecords: histcli.New(sugar), 52 | rio: &rio, 53 | } 54 | go hf.loadHistory(bashHistoryPath, zshHistoryPath, maxInitHistSize, minInitHistSizeKB) 55 | go hf.writer(input, signals, shutdownDone) 56 | go hf.sessionGC(sessionsToDrop) 57 | return &hf 58 | } 59 | 60 | // load records from resh history, reverse, enrich and save 61 | func (h *Histfile) loadCliRecords(recs []record.V1) { 62 | for _, cmdline := range h.bashCmdLines.List { 63 | h.cliRecords.AddCmdLine(cmdline) 64 | } 65 | for _, cmdline := range h.zshCmdLines.List { 66 | h.cliRecords.AddCmdLine(cmdline) 67 | } 68 | for i := len(recs) - 1; i >= 0; i-- { 69 | rec := recs[i] 70 | h.cliRecords.AddRecord(&rec) 71 | } 72 | h.sugar.Infow("RESH history loaded", 73 | "historyRecordsCount", len(h.cliRecords.List), 74 | ) 75 | } 76 | 77 | // loadsHistory from resh_history and if there is not enough of it also load native shell histories 78 | func (h *Histfile) loadHistory(bashHistoryPath, zshHistoryPath string, maxInitHistSize, minInitHistSizeKB int) { 79 | h.sugar.Infow("Checking if resh_history is large enough ...") 80 | fi, err := os.Stat(h.historyPath) 81 | var size int 82 | if err != nil { 83 | h.sugar.Errorw("Failed to stat resh_history file", "error", err) 84 | } else { 85 | size = int(fi.Size()) 86 | } 87 | useNativeHistories := false 88 | if size/1024 < minInitHistSizeKB { 89 | useNativeHistories = true 90 | h.sugar.Warnw("RESH history is too small - loading native bash and zsh history ...") 91 | h.bashCmdLines = records.LoadCmdLinesFromBashFile(h.sugar, bashHistoryPath) 92 | h.sugar.Infow("Bash history loaded", "cmdLineCount", len(h.bashCmdLines.List)) 93 | h.zshCmdLines = records.LoadCmdLinesFromZshFile(h.sugar, zshHistoryPath) 94 | h.sugar.Infow("Zsh history loaded", "cmdLineCount", len(h.zshCmdLines.List)) 95 | // no maxInitHistSize when using native histories 96 | maxInitHistSize = math.MaxInt32 97 | } 98 | h.sugar.Debugw("Loading resh history from file ...", 99 | "historyFile", h.historyPath, 100 | ) 101 | history, err := h.rio.ReadAndFixFile(h.historyPath, 3) 102 | if err != nil { 103 | h.sugar.Fatalf("Failed to read history file: %v", err) 104 | } 105 | h.sugar.Infow("RESH history loaded from file", 106 | "historyFile", h.historyPath, 107 | "recordCount", len(history), 108 | ) 109 | go h.loadCliRecords(history) 110 | // NOTE: keeping this weird interface for now because we might use it in the future 111 | // when we only load bash or zsh history 112 | reshCmdLines := loadCmdLines(h.sugar, history) 113 | h.sugar.Infow("RESH history loaded and processed", 114 | "recordCount", len(reshCmdLines.List), 115 | ) 116 | if !useNativeHistories { 117 | h.bashCmdLines = reshCmdLines 118 | h.zshCmdLines = histlist.Copy(reshCmdLines) 119 | return 120 | } 121 | h.bashCmdLines.AddHistlist(reshCmdLines) 122 | h.sugar.Infow("Processed bash history and resh history together", "cmdLinecount", len(h.bashCmdLines.List)) 123 | h.zshCmdLines.AddHistlist(reshCmdLines) 124 | h.sugar.Infow("Processed zsh history and resh history together", "cmdLineCount", len(h.zshCmdLines.List)) 125 | } 126 | 127 | // sessionGC reads sessionIDs from channel and deletes them from histfile struct 128 | func (h *Histfile) sessionGC(sessionsToDrop chan string) { 129 | for { 130 | func() { 131 | session := <-sessionsToDrop 132 | sugar := h.sugar.With("sessionID", session) 133 | sugar.Debugw("Got session to drop") 134 | h.sessionsMutex.Lock() 135 | defer h.sessionsMutex.Unlock() 136 | if part1, found := h.sessions[session]; found == true { 137 | sugar.Infow("Dropping session") 138 | delete(h.sessions, session) 139 | go h.rio.AppendToFile(h.historyPath, []record.V1{part1.Rec}) 140 | } else { 141 | sugar.Infow("No hanging parts for session - nothing to drop") 142 | } 143 | }() 144 | } 145 | } 146 | 147 | // writer reads records from channel, merges them and writes them to file 148 | func (h *Histfile) writer(collect chan recordint.Collect, signals chan os.Signal, shutdownDone chan string) { 149 | for { 150 | func() { 151 | select { 152 | case rec := <-collect: 153 | part := "2" 154 | if rec.Rec.PartOne { 155 | part = "1" 156 | } 157 | sugar := h.sugar.With( 158 | "recordCmdLine", rec.Rec.CmdLine, 159 | "recordPart", part, 160 | "recordShell", rec.Shell, 161 | ) 162 | sugar.Debugw("Got record") 163 | h.sessionsMutex.Lock() 164 | defer h.sessionsMutex.Unlock() 165 | 166 | // allows nested sessions to merge records properly 167 | mergeID := rec.SessionID + "_" + strconv.Itoa(rec.Shlvl) 168 | sugar = sugar.With("mergeID", mergeID) 169 | if rec.Rec.PartOne { 170 | if _, found := h.sessions[mergeID]; found { 171 | msg := "Got another first part of the records before merging the previous one - overwriting!" 172 | if rec.Shell == "zsh" { 173 | sugar.Warnw(msg) 174 | } else { 175 | sugar.Infow(msg + " Unfortunately this is normal in bash, it can't be prevented.") 176 | } 177 | } 178 | h.sessions[mergeID] = rec 179 | } else { 180 | if part1, found := h.sessions[mergeID]; found == false { 181 | sugar.Warnw("Got second part of record and nothing to merge it with - ignoring!") 182 | } else { 183 | delete(h.sessions, mergeID) 184 | go h.mergeAndWriteRecord(sugar, part1, rec) 185 | } 186 | } 187 | case sig := <-signals: 188 | sugar := h.sugar.With( 189 | "signal", sig.String(), 190 | ) 191 | sugar.Infow("Got signal") 192 | h.sessionsMutex.Lock() 193 | defer h.sessionsMutex.Unlock() 194 | sugar.Debugw("Unlocked mutex") 195 | 196 | for sessID, rec := range h.sessions { 197 | sugar.Warnw("Writing incomplete record for session", 198 | "sessionID", sessID, 199 | ) 200 | h.writeRecord(sugar, rec.Rec) 201 | } 202 | sugar.Debugw("Shutdown successful") 203 | shutdownDone <- "histfile" 204 | return 205 | } 206 | }() 207 | } 208 | } 209 | 210 | func (h *Histfile) writeRecord(sugar *zap.SugaredLogger, rec record.V1) { 211 | h.rio.AppendToFile(h.historyPath, []record.V1{rec}) 212 | } 213 | 214 | func (h *Histfile) mergeAndWriteRecord(sugar *zap.SugaredLogger, part1 recordint.Collect, part2 recordint.Collect) { 215 | rec, err := recutil.Merge(&part1, &part2) 216 | if err != nil { 217 | sugar.Errorw("Error while merging records", "error", err) 218 | return 219 | } 220 | 221 | recV1 := record.V1(rec) 222 | func() { 223 | cmdLine := rec.CmdLine 224 | h.bashCmdLines.AddCmdLine(cmdLine) 225 | h.zshCmdLines.AddCmdLine(cmdLine) 226 | h.cliRecords.AddRecord(&recV1) 227 | }() 228 | 229 | h.rio.AppendToFile(h.historyPath, []record.V1{recV1}) 230 | } 231 | 232 | // TODO: use errors in RecIO 233 | // func writeRecord(sugar *zap.SugaredLogger, rec record.V1, outputPath string) { 234 | // recJSON, err := json.Marshal(rec) 235 | // if err != nil { 236 | // sugar.Errorw("Marshalling error", "error", err) 237 | // return 238 | // } 239 | // f, err := os.OpenFile(outputPath, 240 | // os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 241 | // if err != nil { 242 | // sugar.Errorw("Could not open file", "error", err) 243 | // return 244 | // } 245 | // defer f.Close() 246 | // _, err = f.Write(append(recJSON, []byte("\n")...)) 247 | // if err != nil { 248 | // sugar.Errorw("Error while writing record", 249 | // "recordRaw", rec, 250 | // "error", err, 251 | // ) 252 | // return 253 | // } 254 | // } 255 | 256 | // DumpCliRecords returns enriched records 257 | func (h *Histfile) DumpCliRecords() histcli.Histcli { 258 | // don't forget locks in the future 259 | return h.cliRecords 260 | } 261 | 262 | func loadCmdLines(sugar *zap.SugaredLogger, recs []record.V1) histlist.Histlist { 263 | hl := histlist.New(sugar) 264 | // go from bottom and deduplicate 265 | var cmdLines []string 266 | cmdLinesSet := map[string]bool{} 267 | for i := len(recs) - 1; i >= 0; i-- { 268 | cmdLine := recs[i].CmdLine 269 | if cmdLinesSet[cmdLine] { 270 | continue 271 | } 272 | cmdLinesSet[cmdLine] = true 273 | cmdLines = append([]string{cmdLine}, cmdLines...) 274 | // if len(cmdLines) > limit { 275 | // break 276 | // } 277 | } 278 | // add everything to histlist 279 | for _, cmdLine := range cmdLines { 280 | hl.AddCmdLine(cmdLine) 281 | } 282 | return hl 283 | } 284 | -------------------------------------------------------------------------------- /internal/histio/file.go: -------------------------------------------------------------------------------- 1 | package histio 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sync" 7 | 8 | "github.com/curusarn/resh/internal/recio" 9 | "github.com/curusarn/resh/record" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | type histfile struct { 14 | sugar *zap.SugaredLogger 15 | // deviceID string 16 | path string 17 | 18 | mu sync.RWMutex 19 | data []record.V1 20 | fileinfo os.FileInfo 21 | } 22 | 23 | func newHistfile(sugar *zap.SugaredLogger, path string) *histfile { 24 | return &histfile{ 25 | sugar: sugar.With( 26 | // FIXME: drop V1 once original histfile is gone 27 | "component", "histfileV1", 28 | "path", path, 29 | ), 30 | // deviceID: deviceID, 31 | path: path, 32 | } 33 | } 34 | 35 | func (h *histfile) updateFromFile() error { 36 | rio := recio.New(h.sugar) 37 | // TODO: decide and handle errors 38 | newData, _, err := rio.ReadFile(h.path) 39 | if err != nil { 40 | return fmt.Errorf("could not read history file: %w", err) 41 | } 42 | h.mu.Lock() 43 | defer h.mu.Unlock() 44 | h.data = newData 45 | h.updateFileInfo() 46 | return nil 47 | } 48 | 49 | func (h *histfile) updateFileInfo() error { 50 | info, err := os.Stat(h.path) 51 | if err != nil { 52 | return fmt.Errorf("history file not found: %w", err) 53 | } 54 | h.fileinfo = info 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/histio/histio.go: -------------------------------------------------------------------------------- 1 | package histio 2 | 3 | import ( 4 | "path" 5 | 6 | "github.com/curusarn/resh/record" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | type Histio struct { 11 | sugar *zap.SugaredLogger 12 | histDir string 13 | 14 | thisDeviceID string 15 | thisHistory *histfile 16 | // TODO: remote histories 17 | // moreHistories map[string]*histfile 18 | 19 | recordsToAppend chan record.V1 20 | // recordsToFlag chan recordint.Flag 21 | } 22 | 23 | func New(sugar *zap.SugaredLogger, dataDir, deviceID string) *Histio { 24 | sugarHistio := sugar.With(zap.String("component", "histio")) 25 | histDir := path.Join(dataDir, "history") 26 | currPath := path.Join(histDir, deviceID) 27 | // TODO: file extension for the history, yes or no? (.reshjson vs. ) 28 | 29 | // TODO: discover other history files, exclude current 30 | 31 | return &Histio{ 32 | sugar: sugarHistio, 33 | histDir: histDir, 34 | 35 | thisDeviceID: deviceID, 36 | thisHistory: newHistfile(sugar, currPath), 37 | // moreHistories: ... 38 | } 39 | } 40 | 41 | func (h *Histio) Append(r *record.V1) { 42 | 43 | } 44 | -------------------------------------------------------------------------------- /internal/histlist/histlist.go: -------------------------------------------------------------------------------- 1 | package histlist 2 | 3 | import "go.uber.org/zap" 4 | 5 | // Histlist is a deduplicated list of cmdLines 6 | type Histlist struct { 7 | // TODO: I'm not excited about logger being passed here 8 | sugar *zap.SugaredLogger 9 | // list of commands lines (deduplicated) 10 | List []string 11 | // lookup: cmdLine -> last index 12 | LastIndex map[string]int 13 | } 14 | 15 | // New Histlist 16 | func New(sugar *zap.SugaredLogger) Histlist { 17 | return Histlist{ 18 | sugar: sugar.With("component", "histlist"), 19 | LastIndex: make(map[string]int), 20 | } 21 | } 22 | 23 | // Copy Histlist 24 | func Copy(hl Histlist) Histlist { 25 | newHl := New(hl.sugar) 26 | // copy list 27 | newHl.List = make([]string, len(hl.List)) 28 | copy(newHl.List, hl.List) 29 | // copy map 30 | for k, v := range hl.LastIndex { 31 | newHl.LastIndex[k] = v 32 | } 33 | return newHl 34 | } 35 | 36 | // AddCmdLine to the histlist 37 | func (h *Histlist) AddCmdLine(cmdLine string) { 38 | // lenBefore := len(h.List) 39 | // lookup 40 | idx, found := h.LastIndex[cmdLine] 41 | if found { 42 | // remove duplicate 43 | if cmdLine != h.List[idx] { 44 | h.sugar.DPanicw("Index key is different than actual cmd line in the list", 45 | "indexKeyCmdLine", cmdLine, 46 | "actualCmdLine", h.List[idx], 47 | ) 48 | } 49 | h.List = append(h.List[:idx], h.List[idx+1:]...) 50 | // idx++ 51 | for idx < len(h.List) { 52 | cmdLn := h.List[idx] 53 | h.LastIndex[cmdLn]-- 54 | if idx != h.LastIndex[cmdLn] { 55 | h.sugar.DPanicw("Index position is different than actual position of the cmd line", 56 | "actualPosition", idx, 57 | "indexedPosition", h.LastIndex[cmdLn], 58 | ) 59 | } 60 | idx++ 61 | } 62 | } 63 | // update last index 64 | h.LastIndex[cmdLine] = len(h.List) 65 | // append new cmdline 66 | h.List = append(h.List, cmdLine) 67 | h.sugar.Debugw("Added cmdLine", 68 | "cmdLine", cmdLine, 69 | "historyLength", len(h.List), 70 | ) 71 | } 72 | 73 | // AddHistlist contents of another histlist to this histlist 74 | func (h *Histlist) AddHistlist(h2 Histlist) { 75 | for _, cmdLine := range h2.List { 76 | h.AddCmdLine(cmdLine) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /internal/httpclient/httpclient.go: -------------------------------------------------------------------------------- 1 | package httpclient 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | func New() *http.Client { 9 | return &http.Client{ 10 | Timeout: 500 * time.Millisecond, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/curusarn/resh/internal/datadir" 8 | "go.uber.org/zap" 9 | "go.uber.org/zap/zapcore" 10 | ) 11 | 12 | func New(executable string, level zapcore.Level, development string) (*zap.Logger, error) { 13 | dataDir, err := datadir.MakePath() 14 | if err != nil { 15 | return nil, fmt.Errorf("error while getting RESH data dir: %w", err) 16 | } 17 | logPath := filepath.Join(dataDir, "log.json") 18 | loggerConfig := zap.NewProductionConfig() 19 | loggerConfig.OutputPaths = []string{logPath} 20 | loggerConfig.Level.SetLevel(level) 21 | loggerConfig.Development = development == "true" // DPanic panics in development 22 | logger, err := loggerConfig.Build() 23 | if err != nil { 24 | return logger, fmt.Errorf("error while creating logger: %w", err) 25 | } 26 | return logger.With(zap.String("executable", executable)), err 27 | } 28 | -------------------------------------------------------------------------------- /internal/msg/msg.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import "github.com/curusarn/resh/internal/recordint" 4 | 5 | // CliMsg struct 6 | type CliMsg struct { 7 | SessionID string 8 | PWD string 9 | } 10 | 11 | // CliResponse struct 12 | type CliResponse struct { 13 | Records []recordint.SearchApp 14 | } 15 | 16 | // StatusResponse struct 17 | type StatusResponse struct { 18 | Status bool `json:"status"` 19 | Version string `json:"version"` 20 | Commit string `json:"commit"` 21 | } 22 | -------------------------------------------------------------------------------- /internal/normalize/normailze.go: -------------------------------------------------------------------------------- 1 | package normalize 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | 7 | giturls "github.com/whilp/git-urls" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | // GitRemote helper 12 | // Returns normalized git remote - valid even on error 13 | func GitRemote(sugar *zap.SugaredLogger, gitRemote string) string { 14 | if len(gitRemote) == 0 { 15 | return "" 16 | } 17 | gitRemote = strings.TrimSuffix(gitRemote, ".git") 18 | parsedURL, err := giturls.Parse(gitRemote) 19 | if err != nil { 20 | sugar.Errorw("Failed to parse git remote", zap.Error(err), 21 | "gitRemote", gitRemote, 22 | ) 23 | return gitRemote 24 | } 25 | if parsedURL.User == nil || parsedURL.User.Username() == "" { 26 | parsedURL.User = url.User("git") 27 | } 28 | // TODO: figure out what scheme we want 29 | parsedURL.Scheme = "git+ssh" 30 | return parsedURL.String() 31 | } 32 | -------------------------------------------------------------------------------- /internal/normalize/normalize_test.go: -------------------------------------------------------------------------------- 1 | package normalize_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/curusarn/resh/internal/normalize" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | // TestLeftCutPadString 11 | func TestGitRemote(t *testing.T) { 12 | sugar := zap.NewNop().Sugar() 13 | 14 | data := [][]string{ 15 | { 16 | "git@github.com:curusarn/resh.git", // git 17 | "git@github.com:curusarn/resh", // git no ".git" 18 | "http://github.com/curusarn/resh.git", // http 19 | "https://github.com/curusarn/resh.git", // https 20 | "ssh://git@github.com/curusarn/resh.git", // ssh 21 | "git+ssh://git@github.com/curusarn/resh.git", // git+ssh 22 | }, 23 | { 24 | "git@host.example.com:org/user/repo.git", // git 25 | "git@host.example.com:org/user/repo", // git no ".git" 26 | "http://host.example.com/org/user/repo.git", // http 27 | "https://host.example.com/org/user/repo.git", // https 28 | "ssh://git@host.example.com/org/user/repo.git", // ssh 29 | "git+ssh://git@host.example.com/org/user/repo.git", // git+ssh 30 | }, 31 | } 32 | 33 | for _, arr := range data { 34 | n := len(arr) 35 | for i := 0; i < n-1; i++ { 36 | for j := i + 1; j < n; j++ { 37 | one := normalize.GitRemote(sugar, arr[i]) 38 | two := normalize.GitRemote(sugar, arr[j]) 39 | if one != two { 40 | t.Fatalf("Normalized git remotes should match for '%s' and '%s'\n -> got '%s' != '%s'", 41 | arr[i], arr[j], one, two) 42 | } 43 | } 44 | } 45 | } 46 | 47 | empty := normalize.GitRemote(sugar, "") 48 | if len(empty) != 0 { 49 | t.Fatalf("Normalized git remotes for '' should be ''\n -> got '%s'", empty) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /internal/opt/opt.go: -------------------------------------------------------------------------------- 1 | package opt 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/curusarn/resh/internal/output" 8 | ) 9 | 10 | // HandleVersionOpts reads the first option and handles it 11 | // This is a helper for resh-{collect,postcollect,session-init} commands 12 | func HandleVersionOpts(out *output.Output, args []string, version, commit string) []string { 13 | if len(os.Args) == 0 { 14 | return os.Args[1:] 15 | } 16 | // We use go-like options because of backwards compatibility. 17 | // Not ideal but we should support them because they have worked once 18 | // and adding "more correct" variants would mean supporting more variants. 19 | switch os.Args[1] { 20 | case "-version": 21 | fmt.Print(version) 22 | os.Exit(0) 23 | case "-revision": 24 | fmt.Print(commit) 25 | os.Exit(0) 26 | case "-requireVersion": 27 | if len(os.Args) < 3 { 28 | out.FatalTerminalVersionMismatch(version, "") 29 | } 30 | if os.Args[2] != version { 31 | out.FatalTerminalVersionMismatch(version, os.Args[2]) 32 | } 33 | return os.Args[3:] 34 | } 35 | return os.Args[1:] 36 | } 37 | -------------------------------------------------------------------------------- /internal/output/output.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "go.uber.org/zap" 8 | ) 9 | 10 | // Output wrapper for writing to logger and stdout/stderr at the same time 11 | // useful for errors that should be presented to the user 12 | type Output struct { 13 | Logger *zap.Logger 14 | ErrPrefix string 15 | } 16 | 17 | func New(logger *zap.Logger, prefix string) *Output { 18 | return &Output{ 19 | Logger: logger, 20 | ErrPrefix: prefix, 21 | } 22 | } 23 | 24 | // Info outputs string to stdout and to log (as info) 25 | // This is how we write output to users from interactive commands 26 | // This way we have full record in logs 27 | func (f *Output) Info(msg string) { 28 | fmt.Printf("%s\n", msg) 29 | f.Logger.Info(msg) 30 | } 31 | 32 | // InfoE outputs string to stdout and to log (as error) 33 | // Passed error is only written to log 34 | // This is how we output errors to users from interactive commands 35 | // This way we have errors in logs 36 | func (f *Output) InfoE(msg string, err error) { 37 | fmt.Printf("%s\n", msg) 38 | f.Logger.Error(msg, zap.Error(err)) 39 | } 40 | 41 | // Error outputs string to stderr and to log (as error) 42 | // This is how we output errors from non-interactive commands 43 | func (f *Output) Error(msg string) { 44 | fmt.Fprintf(os.Stderr, "%s: %s\n", f.ErrPrefix, msg) 45 | f.Logger.Error(msg) 46 | } 47 | 48 | // ErrorE outputs string and error to stderr and to log (as error) 49 | // This is how we output errors from non-interactive commands 50 | func (f *Output) ErrorE(msg string, err error) { 51 | fmt.Fprintf(os.Stderr, "%s: %s: %v\n", f.ErrPrefix, msg, err) 52 | f.Logger.Error(msg, zap.Error(err)) 53 | } 54 | 55 | // FatalE outputs string and error to stderr and to log (as fatal) 56 | // This is how we raise fatal errors from non-interactive commands 57 | func (f *Output) FatalE(msg string, err error) { 58 | fmt.Fprintf(os.Stderr, "%s: %s: %v\n", f.ErrPrefix, msg, err) 59 | f.Logger.Fatal(msg, zap.Error(err)) 60 | } 61 | 62 | var msgDaemonNotRunning = `RESH daemon didn't respond - it's probably not running. 63 | -> Start RESH daemon manually - run: resh-daemon-start 64 | -> Or restart this terminal window to bring RESH daemon back up 65 | -> You can check logs: ~/.local/share/resh/log.json (or ~/$XDG_DATA_HOME/resh/log.json) 66 | -> You can create an issue at: https://github.com/curusarn/resh/issues 67 | 68 | ` 69 | var msgTerminalVersionMismatch = `This terminal session was started with different RESH version than is installed now. 70 | It looks like you updated RESH and didn't restart this terminal. 71 | -> Restart this terminal window to fix that 72 | 73 | ` 74 | 75 | var msgDaemonVersionMismatch = `RESH daemon is running in different version than is installed now. 76 | It looks like something went wrong during RESH update. 77 | -> Kill resh-daemon and then launch a new terminal window to fix that: killall resh-daemon 78 | -> You can create an issue at: https://github.com/curusarn/resh/issues 79 | 80 | ` 81 | 82 | func (f *Output) InfoDaemonNotRunning(err error) { 83 | fmt.Printf("%s", msgDaemonNotRunning) 84 | f.Logger.Error("Daemon is not running", zap.Error(err)) 85 | } 86 | 87 | func (f *Output) ErrorDaemonNotRunning(err error) { 88 | fmt.Fprintf(os.Stderr, "%s: %s", f.ErrPrefix, msgDaemonNotRunning) 89 | f.Logger.Error("Daemon is not running", zap.Error(err)) 90 | } 91 | 92 | func (f *Output) FatalDaemonNotRunning(err error) { 93 | fmt.Fprintf(os.Stderr, "%s: %s", f.ErrPrefix, msgDaemonNotRunning) 94 | f.Logger.Fatal("Daemon is not running", zap.Error(err)) 95 | } 96 | 97 | func (f *Output) InfoTerminalVersionMismatch(installedVer, terminalVer string) { 98 | fmt.Printf("%s(installed version: %s, this terminal version: %s)\n\n", 99 | msgTerminalVersionMismatch, installedVer, terminalVer) 100 | f.Logger.Fatal("Version mismatch", 101 | zap.String("installed", installedVer), 102 | zap.String("terminal", terminalVer)) 103 | } 104 | 105 | func (f *Output) ErrorTerminalVersionMismatch(installedVer, terminalVer string) { 106 | fmt.Fprintf(os.Stderr, "%s: %s(installed version: %s, this terminal version: %s)\n\n", 107 | f.ErrPrefix, msgTerminalVersionMismatch, installedVer, terminalVer) 108 | f.Logger.Fatal("Version mismatch", 109 | zap.String("installed", installedVer), 110 | zap.String("terminal", terminalVer)) 111 | } 112 | 113 | func (f *Output) FatalTerminalVersionMismatch(installedVer, terminalVer string) { 114 | fmt.Fprintf(os.Stderr, "%s: %s(installed version: %s, this terminal version: %s)\n\n", 115 | f.ErrPrefix, msgTerminalVersionMismatch, installedVer, terminalVer) 116 | f.Logger.Fatal("Version mismatch", 117 | zap.String("installed", installedVer), 118 | zap.String("terminal", terminalVer)) 119 | } 120 | 121 | func (f *Output) InfoDaemonVersionMismatch(installedVer, daemonVer string) { 122 | fmt.Printf("%s(installed version: %s, running daemon version: %s)\n\n", 123 | msgDaemonVersionMismatch, installedVer, daemonVer) 124 | f.Logger.Error("Version mismatch", 125 | zap.String("installed", installedVer), 126 | zap.String("daemon", daemonVer)) 127 | } 128 | 129 | func (f *Output) ErrorDaemonVersionMismatch(installedVer, daemonVer string) { 130 | fmt.Fprintf(os.Stderr, "%s: %s(installed version: %s, running daemon version: %s)\n\n", 131 | f.ErrPrefix, msgDaemonVersionMismatch, installedVer, daemonVer) 132 | f.Logger.Error("Version mismatch", 133 | zap.String("installed", installedVer), 134 | zap.String("daemon", daemonVer)) 135 | } 136 | 137 | func (f *Output) FatalDaemonVersionMismatch(installedVer, daemonVer string) { 138 | fmt.Fprintf(os.Stderr, "%s: %s(installed version: %s, running daemon version: %s)\n\n", 139 | f.ErrPrefix, msgDaemonVersionMismatch, installedVer, daemonVer) 140 | f.Logger.Fatal("Version mismatch", 141 | zap.String("installed", installedVer), 142 | zap.String("daemon", daemonVer)) 143 | } 144 | -------------------------------------------------------------------------------- /internal/recconv/recconv.go: -------------------------------------------------------------------------------- 1 | package recconv 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/curusarn/resh/record" 7 | ) 8 | 9 | func LegacyToV1(r *record.Legacy) *record.V1 { 10 | return &record.V1{ 11 | // FIXME: fill in all the fields 12 | 13 | // Flags: 0, 14 | 15 | CmdLine: r.CmdLine, 16 | ExitCode: r.ExitCode, 17 | 18 | DeviceID: r.ReshUUID, 19 | SessionID: r.SessionID, 20 | RecordID: r.RecordID, 21 | 22 | Home: r.Home, 23 | Pwd: r.Pwd, 24 | RealPwd: r.RealPwd, 25 | 26 | // Logname: r.Login, 27 | Device: r.Host, 28 | 29 | GitOriginRemote: r.GitOriginRemote, 30 | 31 | Time: fmt.Sprintf("%.4f", r.RealtimeBefore), 32 | Duration: fmt.Sprintf("%.4f", r.RealtimeDuration), 33 | 34 | PartOne: r.PartOne, 35 | PartsNotMerged: !r.PartsMerged, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/recio/read.go: -------------------------------------------------------------------------------- 1 | package recio 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | "github.com/curusarn/resh/internal/futil" 12 | "github.com/curusarn/resh/internal/recconv" 13 | "github.com/curusarn/resh/record" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | func (r *RecIO) ReadAndFixFile(fpath string, maxErrors int) ([]record.V1, error) { 18 | recs, decodeErrs, err := r.ReadFile(fpath) 19 | if err != nil { 20 | return nil, err 21 | } 22 | numErrs := len(decodeErrs) 23 | if numErrs > maxErrors { 24 | r.sugar.Errorw("Encountered too many decoding errors", 25 | "errorsCount", numErrs, 26 | "individualErrors", "", 27 | ) 28 | return nil, fmt.Errorf("encountered too many decoding errors, last error: %w", decodeErrs[len(decodeErrs)-1]) 29 | } 30 | if numErrs == 0 { 31 | return recs, nil 32 | } 33 | 34 | r.sugar.Warnw("Some history records could not be decoded - fixing RESH history file by dropping them", 35 | "corruptedRecords", numErrs, 36 | "lastError", decodeErrs[len(decodeErrs)-1], 37 | "individualErrors", "", 38 | ) 39 | 40 | fpathBak := fpath + ".bak" 41 | r.sugar.Infow("Backing up current corrupted history file", 42 | "historyFileBackup", fpathBak, 43 | ) 44 | err = futil.CopyFile(fpath, fpathBak) 45 | if err != nil { 46 | r.sugar.Errorw("Failed to create a backup history file - aborting fixing history file", 47 | "historyFileBackup", fpathBak, 48 | zap.Error(err), 49 | ) 50 | return recs, nil 51 | } 52 | r.sugar.Info("Writing resh history file without errors ...") 53 | err = r.OverwriteFile(fpath, recs) 54 | if err != nil { 55 | r.sugar.Errorw("Failed write fixed history file - restoring history file from backup", 56 | "historyFile", fpath, 57 | zap.Error(err), 58 | ) 59 | 60 | err = futil.CopyFile(fpathBak, fpath) 61 | if err != nil { 62 | r.sugar.Errorw("Failed restore history file from backup", 63 | "historyFile", fpath, 64 | "HistoryFileBackup", fpathBak, 65 | zap.Error(err), 66 | ) 67 | } 68 | } 69 | return recs, nil 70 | } 71 | 72 | func (r *RecIO) ReadFile(fpath string) ([]record.V1, []error, error) { 73 | var recs []record.V1 74 | file, err := os.Open(fpath) 75 | if err != nil { 76 | return nil, nil, fmt.Errorf("failed to open history file: %w", err) 77 | } 78 | defer file.Close() 79 | 80 | reader := bufio.NewReader(file) 81 | var decodeErrs []error 82 | for { 83 | var line string 84 | line, err = reader.ReadString('\n') 85 | if err != nil { 86 | break 87 | } 88 | rec, err := r.decodeLine(line) 89 | if err != nil { 90 | r.sugar.Errorw("Error while decoding line", zap.Error(err), 91 | "filePath", fpath, 92 | "line", line, 93 | ) 94 | decodeErrs = append(decodeErrs, err) 95 | continue 96 | } 97 | recs = append(recs, *rec) 98 | } 99 | if err != io.EOF { 100 | r.sugar.Error("Error while reading file", zap.Error(err)) 101 | return recs, decodeErrs, err 102 | } 103 | r.sugar.Infow("Loaded resh history records", 104 | "recordCount", len(recs), 105 | ) 106 | return recs, decodeErrs, nil 107 | } 108 | 109 | func (r *RecIO) decodeLine(line string) (*record.V1, error) { 110 | idx := strings.Index(line, "{") 111 | if idx == -1 { 112 | return nil, fmt.Errorf("no opening brace found") 113 | } 114 | schema := line[:idx] 115 | jsn := line[idx:] 116 | switch schema { 117 | case "v1": 118 | var rec record.V1 119 | err := decodeAnyRecord(jsn, &rec) 120 | if err != nil { 121 | return nil, err 122 | } 123 | return &rec, nil 124 | case "": 125 | var rec record.Legacy 126 | err := decodeAnyRecord(jsn, &rec) 127 | if err != nil { 128 | return nil, err 129 | } 130 | return recconv.LegacyToV1(&rec), nil 131 | default: 132 | return nil, fmt.Errorf("unknown record schema/type '%s'", schema) 133 | } 134 | } 135 | 136 | // TODO: find out if we are loosing performance because of the use of interface{} 137 | 138 | func decodeAnyRecord(jsn string, rec interface{}) error { 139 | err := json.Unmarshal([]byte(jsn), &rec) 140 | if err != nil { 141 | return fmt.Errorf("failed to decode json: %w", err) 142 | } 143 | return nil 144 | } 145 | -------------------------------------------------------------------------------- /internal/recio/recio.go: -------------------------------------------------------------------------------- 1 | package recio 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | ) 6 | 7 | type RecIO struct { 8 | sugar *zap.SugaredLogger 9 | } 10 | 11 | func New(sugar *zap.SugaredLogger) RecIO { 12 | return RecIO{sugar: sugar} 13 | } 14 | -------------------------------------------------------------------------------- /internal/recio/write.go: -------------------------------------------------------------------------------- 1 | package recio 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/curusarn/resh/record" 9 | ) 10 | 11 | func (r *RecIO) OverwriteFile(fpath string, recs []record.V1) error { 12 | file, err := os.Create(fpath) 13 | if err != nil { 14 | return fmt.Errorf("could not create/truncate file: %w", err) 15 | } 16 | err = writeRecords(file, recs) 17 | if err != nil { 18 | return fmt.Errorf("error while writing records: %w", err) 19 | } 20 | err = file.Close() 21 | if err != nil { 22 | return fmt.Errorf("could not close file: %w", err) 23 | } 24 | return nil 25 | } 26 | 27 | func (r *RecIO) AppendToFile(fpath string, recs []record.V1) error { 28 | file, err := os.OpenFile(fpath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 29 | if err != nil { 30 | return fmt.Errorf("could not open/create file: %w", err) 31 | } 32 | err = writeRecords(file, recs) 33 | if err != nil { 34 | return fmt.Errorf("error while writing records: %w", err) 35 | } 36 | err = file.Close() 37 | if err != nil { 38 | return fmt.Errorf("could not close file: %w", err) 39 | } 40 | return nil 41 | } 42 | 43 | func writeRecords(file *os.File, recs []record.V1) error { 44 | for _, rec := range recs { 45 | jsn, err := encodeV1Record(rec) 46 | if err != nil { 47 | return fmt.Errorf("could not encode record: %w", err) 48 | } 49 | _, err = file.Write(jsn) 50 | if err != nil { 51 | return fmt.Errorf("could not write json: %w", err) 52 | } 53 | } 54 | return nil 55 | } 56 | 57 | func encodeV1Record(rec record.V1) ([]byte, error) { 58 | version := []byte("v1") 59 | jsn, err := json.Marshal(rec) 60 | if err != nil { 61 | return nil, fmt.Errorf("failed to encode json: %w", err) 62 | } 63 | return append(append(version, jsn...), []byte("\n")...), nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/recordint/collect.go: -------------------------------------------------------------------------------- 1 | package recordint 2 | 3 | import "github.com/curusarn/resh/record" 4 | 5 | type Collect struct { 6 | // record merging 7 | SessionID string 8 | Shlvl int 9 | // session watching 10 | SessionPID int 11 | Shell string 12 | 13 | Rec record.V1 14 | } 15 | 16 | type Postcollect struct { 17 | // record merging 18 | SessionID string 19 | Shlvl int 20 | // session watching 21 | SessionPID int 22 | 23 | RecordID string 24 | ExitCode int 25 | Duration float64 26 | } 27 | 28 | type SessionInit struct { 29 | // record merging 30 | SessionID string 31 | Shlvl int 32 | // session watching 33 | SessionPID int 34 | } 35 | -------------------------------------------------------------------------------- /internal/recordint/recordint.go: -------------------------------------------------------------------------------- 1 | // Package recordint provides internal record types that are passed between resh components 2 | package recordint 3 | -------------------------------------------------------------------------------- /internal/recordint/searchapp.go: -------------------------------------------------------------------------------- 1 | package recordint 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/curusarn/resh/internal/normalize" 7 | "github.com/curusarn/resh/record" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | // SearchApp record used for sending records to RESH-CLI 12 | type SearchApp struct { 13 | IsRaw bool 14 | SessionID string 15 | DeviceID string 16 | 17 | CmdLine string 18 | Host string 19 | Pwd string 20 | Home string // helps us to collapse /home/user to tilde 21 | GitOriginRemote string 22 | ExitCode int 23 | 24 | Time float64 25 | 26 | // file index 27 | Idx int 28 | } 29 | 30 | func NewSearchAppFromCmdLine(cmdLine string) SearchApp { 31 | return SearchApp{ 32 | IsRaw: true, 33 | CmdLine: cmdLine, 34 | } 35 | } 36 | 37 | // The error handling here could be better 38 | func NewSearchApp(sugar *zap.SugaredLogger, r *record.V1) SearchApp { 39 | time, err := strconv.ParseFloat(r.Time, 64) 40 | if err != nil { 41 | sugar.Errorw("Error while parsing time as float", zap.Error(err), 42 | "time", time) 43 | } 44 | return SearchApp{ 45 | IsRaw: false, 46 | SessionID: r.SessionID, 47 | CmdLine: r.CmdLine, 48 | Host: r.Device, 49 | Pwd: r.Pwd, 50 | Home: r.Home, 51 | // TODO: is this the right place to normalize the git remote? 52 | GitOriginRemote: normalize.GitRemote(sugar, r.GitOriginRemote), 53 | ExitCode: r.ExitCode, 54 | Time: time, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/records/records.go: -------------------------------------------------------------------------------- 1 | package records 2 | 3 | // DEPRECATION NOTICE: This package should be removed in favor of: 4 | // - record: public record definitions 5 | // - recordint: internal record definitions 6 | // - recutil: record-related utils 7 | 8 | import ( 9 | "bufio" 10 | "os" 11 | "strings" 12 | 13 | "github.com/curusarn/resh/internal/histlist" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | // LoadCmdLinesFromZshFile loads cmdlines from zsh history file 18 | func LoadCmdLinesFromZshFile(sugar *zap.SugaredLogger, fname string) histlist.Histlist { 19 | hl := histlist.New(sugar) 20 | file, err := os.Open(fname) 21 | if err != nil { 22 | sugar.Error("Failed to open zsh history file - skipping reading zsh history", zap.Error(err)) 23 | return hl 24 | } 25 | defer file.Close() 26 | 27 | scanner := bufio.NewScanner(file) 28 | for scanner.Scan() { 29 | line := scanner.Text() 30 | // trim newline 31 | line = strings.TrimRight(line, "\n") 32 | var cmd string 33 | // zsh format EXTENDED_HISTORY 34 | // : 1576270617:0;make install 35 | // zsh format no EXTENDED_HISTORY 36 | // make install 37 | if len(line) == 0 { 38 | // skip empty 39 | continue 40 | } 41 | if strings.Contains(line, ":") && strings.Contains(line, ";") && 42 | len(strings.Split(line, ":")) >= 3 && len(strings.Split(line, ";")) >= 2 { 43 | // contains at least 2x ':' and 1x ';' => assume EXTENDED_HISTORY 44 | cmd = strings.Split(line, ";")[1] 45 | } else { 46 | cmd = line 47 | } 48 | hl.AddCmdLine(cmd) 49 | } 50 | return hl 51 | } 52 | 53 | // LoadCmdLinesFromBashFile loads cmdlines from bash history file 54 | func LoadCmdLinesFromBashFile(sugar *zap.SugaredLogger, fname string) histlist.Histlist { 55 | hl := histlist.New(sugar) 56 | file, err := os.Open(fname) 57 | if err != nil { 58 | sugar.Error("Failed to open bash history file - skipping reading bash history", zap.Error(err)) 59 | return hl 60 | } 61 | defer file.Close() 62 | 63 | scanner := bufio.NewScanner(file) 64 | for scanner.Scan() { 65 | line := scanner.Text() 66 | // trim newline 67 | line = strings.TrimRight(line, "\n") 68 | // trim spaces from left 69 | line = strings.TrimLeft(line, " ") 70 | // bash format (two lines) 71 | // #1576199174 72 | // make install 73 | if strings.HasPrefix(line, "#") { 74 | // is either timestamp or comment => skip 75 | continue 76 | } 77 | if len(line) == 0 { 78 | // skip empty 79 | continue 80 | } 81 | hl.AddCmdLine(line) 82 | } 83 | return hl 84 | } 85 | -------------------------------------------------------------------------------- /internal/recutil/recutil.go: -------------------------------------------------------------------------------- 1 | package recutil 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/curusarn/resh/internal/recordint" 7 | "github.com/curusarn/resh/record" 8 | ) 9 | 10 | // TODO: reintroduce validation 11 | // Validate returns error if the record is invalid 12 | // func Validate(r *record.V1) error { 13 | // if r.CmdLine == "" { 14 | // return errors.New("There is no CmdLine") 15 | // } 16 | // if r.Time == 0 { 17 | // return errors.New("There is no Time") 18 | // } 19 | // if r.RealPwd == "" { 20 | // return errors.New("There is no Real Pwd") 21 | // } 22 | // if r.Pwd == "" { 23 | // return errors.New("There is no Pwd") 24 | // } 25 | // return nil 26 | // } 27 | 28 | // TODO: maybe more to a more appropriate place 29 | // TODO: cleanup the interface - stop modifying the part1 and returning a new record at the same time 30 | // Merge two records (part1 - collect + part2 - postcollect) 31 | func Merge(r1 *recordint.Collect, r2 *recordint.Collect) (record.V1, error) { 32 | if r1.SessionID != r2.SessionID { 33 | return record.V1{}, errors.New("Records to merge are not from the same session - r1:" + r1.SessionID + " r2:" + r2.SessionID) 34 | } 35 | if r1.Rec.RecordID != r2.Rec.RecordID { 36 | return record.V1{}, errors.New("Records to merge do not have the same ID - r1:" + r1.Rec.RecordID + " r2:" + r2.Rec.RecordID) 37 | } 38 | 39 | r := recordint.Collect{ 40 | SessionID: r1.SessionID, 41 | Shlvl: r1.Shlvl, 42 | SessionPID: r1.SessionPID, 43 | 44 | Rec: r1.Rec, 45 | } 46 | r.Rec.ExitCode = r2.Rec.ExitCode 47 | r.Rec.Duration = r2.Rec.Duration 48 | r.Rec.PartOne = false 49 | r.Rec.PartsNotMerged = false 50 | return r.Rec, nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/searchapp/highlight.go: -------------------------------------------------------------------------------- 1 | package searchapp 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | func cleanHighlight(str string) string { 9 | prefix := "\033[" 10 | 11 | invert := "\033[7;1m" 12 | invertGreen := "\033[32;7;1m" 13 | end := "\033[0m" 14 | replace := []string{invert, invertGreen, end} 15 | for i := 30; i < 48; i++ { 16 | base := prefix + strconv.Itoa(i) 17 | normal := base + "m" 18 | bold := base + ";1m" 19 | replace = append(replace, normal, bold) 20 | } 21 | if strings.Contains(str, prefix) == false { 22 | return str 23 | } 24 | for _, escSeq := range replace { 25 | str = strings.ReplaceAll(str, escSeq, "") 26 | } 27 | return str 28 | } 29 | 30 | func highlightHeader(str string) string { 31 | underline := "\033[4m" 32 | end := "\033[0m" 33 | // no clean highlight 34 | return underline + str + end 35 | } 36 | 37 | func highlightStatus(str string) string { 38 | invert := "\033[7;1m" 39 | end := "\033[0m" 40 | return invert + cleanHighlight(str) + end 41 | } 42 | 43 | func highlightSelected(str string) string { 44 | // template "\033[3%d;%dm" 45 | // invertGreen := "\033[32;7;1m" 46 | invert := "\033[7;1m" 47 | end := "\033[0m" 48 | return invert + cleanHighlight(str) + end 49 | } 50 | 51 | func highlightDate(str string) string { 52 | // template "\033[3%d;%dm" 53 | yellowNormal := "\033[33m" 54 | end := "\033[0m" 55 | return yellowNormal + cleanHighlight(str) + end 56 | } 57 | 58 | func highlightHost(str string) string { 59 | // template "\033[3%d;%dm" 60 | redNormal := "\033[31m" 61 | end := "\033[0m" 62 | return redNormal + cleanHighlight(str) + end 63 | } 64 | 65 | func highlightPwd(str string) string { 66 | // template "\033[3%d;%dm" 67 | blueBold := "\033[34;1m" 68 | end := "\033[0m" 69 | return blueBold + cleanHighlight(str) + end 70 | } 71 | 72 | func highlightMatch(str string) string { 73 | // template "\033[3%d;%dm" 74 | magentaBold := "\033[35;1m" 75 | end := "\033[0m" 76 | return magentaBold + cleanHighlight(str) + end 77 | } 78 | 79 | func highlightWarn(str string) string { 80 | // template "\033[3%d;%dm" 81 | // orangeBold := "\033[33;1m" 82 | redBold := "\033[31;1m" 83 | end := "\033[0m" 84 | return redBold + cleanHighlight(str) + end 85 | } 86 | 87 | func highlightGit(str string) string { 88 | // template "\033[3%d;%dm" 89 | greenBold := "\033[32;1m" 90 | end := "\033[0m" 91 | return greenBold + cleanHighlight(str) + end 92 | } 93 | 94 | // DoHighlightHeader . 95 | func DoHighlightHeader(str string, minLength int) string { 96 | if len(str) < minLength { 97 | str = str + strings.Repeat(" ", minLength-len(str)) 98 | } 99 | return highlightHeader(str) 100 | } 101 | 102 | // DoHighlightString . 103 | func DoHighlightString(str string, minLength int) string { 104 | if len(str) < minLength { 105 | str = str + strings.Repeat(" ", minLength-len(str)) 106 | } 107 | return highlightSelected(str) 108 | } 109 | 110 | // EXTRAS 111 | 112 | func highlightModeTitle(str string) string { 113 | // template "\033[3%d;%dm" 114 | greenNormal := "\033[32;1m" 115 | end := "\033[0m" 116 | return greenNormal + cleanHighlight(str) + end 117 | } 118 | -------------------------------------------------------------------------------- /internal/searchapp/item.go: -------------------------------------------------------------------------------- 1 | package searchapp 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strconv" 7 | "strings" 8 | "time" 9 | "unicode" 10 | 11 | "github.com/curusarn/resh/internal/recordint" 12 | "golang.org/x/exp/utf8string" 13 | ) 14 | 15 | const itemLocationLength = 30 16 | const dots = "…" 17 | 18 | // Item holds item info for normal mode 19 | type Item struct { 20 | isRaw bool 21 | 22 | time float64 23 | 24 | // [host:]pwd 25 | differentHost bool 26 | host string 27 | home string 28 | samePwd bool 29 | pwd string 30 | 31 | // [G] [E#] 32 | sameGitRepo bool 33 | exitCode int 34 | 35 | // Shown in TUI 36 | CmdLineWithColor string 37 | CmdLine string 38 | // Unchanged cmdline to paste to command line 39 | CmdLineOut string 40 | 41 | Score float64 42 | 43 | Key string 44 | // cmdLineRaw string 45 | } 46 | 47 | // ItemColumns holds rendered columns 48 | type ItemColumns struct { 49 | DateWithColor string 50 | Date string 51 | 52 | // [host:]pwd 53 | differentHost bool 54 | Host string 55 | PwdTilde string 56 | samePwd bool 57 | //locationWithColor string 58 | //location string 59 | 60 | // [G] [E#] 61 | FlagsWithColor string 62 | Flags string 63 | 64 | // Shown in TUI 65 | CmdLineWithColor string 66 | CmdLine string 67 | 68 | // score float64 69 | 70 | Key string 71 | // cmdLineRaw string 72 | } 73 | 74 | func (i Item) less(i2 Item) bool { 75 | // reversed order 76 | return i.Score > i2.Score 77 | } 78 | 79 | func splitStatusLineToLines(statusLine string, printedLineLength, realLineLength int) []string { 80 | var statusLineSlice []string 81 | // status line 82 | var idxSt, idxEnd int 83 | var nextLine bool 84 | tab := " " 85 | tabSize := len(tab) 86 | for idxSt < len(statusLine) { 87 | idxEnd = idxSt + printedLineLength 88 | if nextLine { 89 | idxEnd -= tabSize 90 | } 91 | 92 | if idxEnd > len(statusLine) { 93 | idxEnd = len(statusLine) 94 | } 95 | str := statusLine[idxSt:idxEnd] 96 | 97 | indent := " " 98 | if nextLine { 99 | indent += tab 100 | } 101 | statusLineSlice = append(statusLineSlice, highlightStatus(rightCutPadString(indent+str, realLineLength))+"\n") 102 | idxSt += printedLineLength 103 | nextLine = true 104 | } 105 | return statusLineSlice 106 | } 107 | 108 | // DrawStatusLine ... 109 | func (i Item) DrawStatusLine(compactRendering bool, printedLineLength, realLineLength int) []string { 110 | if i.isRaw { 111 | return splitStatusLineToLines(i.CmdLine, printedLineLength, realLineLength) 112 | } 113 | secs := int64(i.time) 114 | nsecs := int64((i.time - float64(secs)) * 1e9) 115 | tm := time.Unix(secs, nsecs) 116 | const timeFormat = "2006-01-02 15:04:05" 117 | timeString := tm.Format(timeFormat) 118 | 119 | pwdTilde := strings.Replace(i.pwd, i.home, "~", 1) 120 | 121 | separator := " " 122 | stLine := timeString + separator + i.host + ":" + pwdTilde + separator + i.CmdLine 123 | return splitStatusLineToLines(stLine, printedLineLength, realLineLength) 124 | } 125 | 126 | // GetEmptyStatusLine . 127 | func GetEmptyStatusLine(printedLineLength, realLineLength int) []string { 128 | return splitStatusLineToLines("- no result selected -", printedLineLength, realLineLength) 129 | } 130 | 131 | // DrawItemColumns ... 132 | func (i Item) DrawItemColumns(compactRendering bool, debug bool) ItemColumns { 133 | if i.isRaw { 134 | notAvailable := "n/a" 135 | return ItemColumns{ 136 | Date: notAvailable + " ", 137 | DateWithColor: notAvailable + " ", 138 | // dateWithColor: highlightDate(notAvailable) + " ", 139 | Host: "", 140 | PwdTilde: notAvailable, 141 | CmdLine: i.CmdLine, 142 | CmdLineWithColor: i.CmdLineWithColor, 143 | // score: i.score, 144 | Key: i.Key, 145 | } 146 | } 147 | 148 | // DISPLAY 149 | // DISPLAY > date 150 | secs := int64(i.time) 151 | nsecs := int64((i.time - float64(secs)) * 1e9) 152 | tm := time.Unix(secs, nsecs) 153 | 154 | var date string 155 | if compactRendering { 156 | date = formatTimeRelativeShort(tm) + " " 157 | } else { 158 | date = formatTimeRelativeLong(tm) + " " 159 | } 160 | dateWithColor := highlightDate(date) 161 | // DISPLAY > location 162 | // DISPLAY > location > host 163 | host := "" 164 | if i.differentHost { 165 | host += i.host 166 | } 167 | // DISPLAY > location > directory 168 | pwdTilde := strings.Replace(i.pwd, i.home, "~", 1) 169 | 170 | // DISPLAY > flags 171 | flags := "" 172 | flagsWithColor := "" 173 | if debug { 174 | hitsStr := fmt.Sprintf("%.1f", i.Score) 175 | flags += " S" + hitsStr 176 | flagsWithColor += " S" + hitsStr 177 | } 178 | if i.sameGitRepo { 179 | flags += " G" 180 | flagsWithColor += " " + highlightGit("G") 181 | } 182 | if i.exitCode != 0 { 183 | flags += " E" + strconv.Itoa(i.exitCode) 184 | flagsWithColor += " " + highlightWarn("E"+strconv.Itoa(i.exitCode)) 185 | } 186 | // NOTE: you can debug arbitrary metadata like this 187 | // flags += " <" + record.GitOriginRemote + ">" 188 | // flagsWithColor += " <" + record.GitOriginRemote + ">" 189 | return ItemColumns{ 190 | Date: date, 191 | DateWithColor: dateWithColor, 192 | Host: host, 193 | PwdTilde: pwdTilde, 194 | samePwd: i.samePwd, 195 | differentHost: i.differentHost, 196 | Flags: flags, 197 | FlagsWithColor: flagsWithColor, 198 | CmdLine: i.CmdLine, 199 | CmdLineWithColor: i.CmdLineWithColor, 200 | // score: i.score, 201 | Key: i.Key, 202 | } 203 | } 204 | 205 | func minInt(values ...int) int { 206 | min := math.MaxInt32 207 | for _, val := range values { 208 | if val < min { 209 | min = val 210 | } 211 | } 212 | return min 213 | } 214 | 215 | func produceLocation(length int, host string, pwdTilde string, differentHost bool, samePwd bool, debug bool) string { 216 | hostLen := len(host) 217 | if hostLen <= 0 { 218 | pwdWithColor := leftCutPadString(pwdTilde, length) 219 | if samePwd { 220 | pwdWithColor = highlightPwd(pwdWithColor) 221 | } 222 | return pwdWithColor 223 | } 224 | colonLen := 1 225 | pwdLen := len(pwdTilde) 226 | totalLen := hostLen + colonLen + pwdLen 227 | 228 | newHostLen := hostLen 229 | // only shrink if the location does not fit 230 | if totalLen > length { 231 | // how much we need to shrink/crop the location 232 | shrinkFactor := float64(length) / float64(totalLen) 233 | 234 | shrinkedHostLen := int(math.Ceil(float64(hostLen) * shrinkFactor)) 235 | halfLocationLen := length/2 - colonLen 236 | 237 | newHostLen = minInt(hostLen, shrinkedHostLen, halfLocationLen) 238 | } 239 | // pwd length is the rest of the length 240 | newPwdLen := length - colonLen - newHostLen 241 | 242 | // adjust pwd length 243 | if newPwdLen > pwdLen { 244 | diff := newPwdLen - pwdLen 245 | newHostLen += diff 246 | newPwdLen -= diff 247 | } 248 | 249 | hostWithColor := rightCutLeftPadString(host, newHostLen) 250 | if differentHost { 251 | hostWithColor = highlightHost(hostWithColor) 252 | } 253 | pwdWithColor := leftCutPadString(pwdTilde, newPwdLen) 254 | if samePwd { 255 | pwdWithColor = highlightPwd(pwdWithColor) 256 | } 257 | return hostWithColor + ":" + pwdWithColor 258 | } 259 | 260 | // ProduceLine ... 261 | func (ic ItemColumns) ProduceLine(dateLength int, locationLength int, flagsLength int, header bool, showDate bool, debug bool) (string, int, error) { 262 | var err error 263 | line := "" 264 | if showDate { 265 | line += strings.Repeat(" ", dateLength-len(ic.Date)) + ic.DateWithColor 266 | } 267 | // LOCATION 268 | locationWithColor := produceLocation(locationLength, ic.Host, ic.PwdTilde, ic.differentHost, ic.samePwd, debug) 269 | line += locationWithColor 270 | 271 | // FLAGS 272 | line += ic.FlagsWithColor 273 | if flagsLength >= len(ic.Flags) { 274 | line += strings.Repeat(" ", flagsLength-len(ic.Flags)) 275 | } else { 276 | err = fmt.Errorf("actual flags are longer than dedicated flag space. actual: %v, space: %v", len(ic.Flags), flagsLength) 277 | } 278 | spacer := " " 279 | if flagsLength > 5 || header { 280 | // use shorter spacer 281 | // because there is likely a long flag like E130 in the view 282 | spacer = " " 283 | } 284 | line += spacer + ic.CmdLineWithColor 285 | 286 | length := dateLength + locationLength + flagsLength + len(spacer) + len(ic.CmdLine) 287 | return line, length, err 288 | } 289 | 290 | func rightCutLeftPadString(str string, newLen int) string { 291 | if newLen <= 0 { 292 | return "" 293 | } 294 | utf8Str := utf8string.NewString(str) 295 | strLen := utf8Str.RuneCount() 296 | if newLen > strLen { 297 | return strings.Repeat(" ", newLen-strLen) + str 298 | } else if newLen < strLen { 299 | return utf8Str.Slice(0, newLen-1) + dots 300 | } 301 | return str 302 | } 303 | 304 | func leftCutPadString(str string, newLen int) string { 305 | if newLen <= 0 { 306 | return "" 307 | } 308 | utf8Str := utf8string.NewString(str) 309 | strLen := utf8Str.RuneCount() 310 | if newLen > strLen { 311 | return strings.Repeat(" ", newLen-strLen) + str 312 | } else if newLen < strLen { 313 | return dots + utf8string.NewString(str).Slice(strLen-newLen+1, strLen) 314 | } 315 | return str 316 | } 317 | 318 | func rightCutPadString(str string, newLen int) string { 319 | if newLen <= 0 { 320 | return "" 321 | } 322 | utf8Str := utf8string.NewString(str) 323 | strLen := utf8Str.RuneCount() 324 | if newLen > strLen { 325 | return str + strings.Repeat(" ", newLen-strLen) 326 | } else if newLen < strLen { 327 | return utf8Str.Slice(0, newLen-1) + dots 328 | } 329 | return str 330 | } 331 | 332 | // proper match for path is when whole directory is matched 333 | // proper match for command is when term matches word delimited by whitespace 334 | func properMatch(str, term, padChar string) bool { 335 | return strings.Contains(padChar+str+padChar, padChar+term+padChar) 336 | } 337 | 338 | func trimCmdLine(cmdLine string) string { 339 | return strings.TrimRightFunc(cmdLine, unicode.IsSpace) 340 | } 341 | 342 | func replaceNewLines(cmdLine string) string { 343 | return strings.ReplaceAll(cmdLine, "\n", "\\n ") 344 | } 345 | 346 | // NewItemFromRecordForQuery creates new item from record based on given query 347 | // 348 | // returns error if the query doesn't match the record 349 | func NewItemFromRecordForQuery(record recordint.SearchApp, query Query, debug bool) (Item, error) { 350 | // Use numbers that won't add up to same score for any number of query words 351 | // query score weight 1.51 352 | const hitScore = 1.517 // 1 * 1.51 353 | const properMatchScore = 0.501 // 0.33 * 1.51 354 | const hitScoreConsecutive = 0.00302 // 0.002 * 1.51 355 | 356 | // context score weight 1 357 | // Host penalty 358 | var actualPwdScore = 0.9 359 | var sameGitRepoScore = 0.8 360 | var nonZeroExitCodeScorePenalty = 0.4 361 | var differentHostScorePenalty = 0.2 362 | 363 | reduceHostPenalty := false 364 | if reduceHostPenalty { 365 | actualPwdScore = 0.9 366 | sameGitRepoScore = 0.7 367 | nonZeroExitCodeScorePenalty = 0.4 368 | differentHostScorePenalty = 0.1 369 | } 370 | 371 | const timeScoreCoef = 1e-13 372 | // nonZeroExitCodeScorePenalty + differentHostScorePenalty 373 | 374 | // Trim trailing whitespace before highlighting 375 | trimmedCmdLine := trimCmdLine(record.CmdLine) 376 | 377 | // KEY for deduplication 378 | key := trimmedCmdLine 379 | 380 | score := 0.0 381 | anyHit := false 382 | cmd := trimmedCmdLine 383 | for _, term := range query.terms { 384 | c := strings.Count(record.CmdLine, term) 385 | if c > 0 { 386 | anyHit = true 387 | score += hitScore + hitScoreConsecutive*float64(c) 388 | if properMatch(cmd, term, " ") { 389 | score += properMatchScore 390 | } 391 | cmd = strings.ReplaceAll(cmd, term, highlightMatch(term)) 392 | } 393 | } 394 | // DISPLAY > cmdline 395 | 396 | // cmd := "<" + strings.ReplaceAll(record.CmdLine, "\n", ";") + ">" 397 | cmdLine := replaceNewLines(trimmedCmdLine) 398 | cmdLineWithColor := replaceNewLines(cmd) 399 | 400 | if record.IsRaw { 401 | return Item{ 402 | isRaw: true, 403 | 404 | CmdLineOut: record.CmdLine, 405 | CmdLine: cmdLine, 406 | CmdLineWithColor: cmdLineWithColor, 407 | Score: score, 408 | Key: key, 409 | }, nil 410 | } 411 | // actual pwd matches 412 | // N terms can only produce: 413 | // -> N matches against the command 414 | // -> 1 extra match for the actual directory match 415 | sameGitRepo := false 416 | if len(query.gitOriginRemote) != 0 && query.gitOriginRemote == record.GitOriginRemote { 417 | sameGitRepo = true 418 | } 419 | 420 | samePwd := false 421 | if record.Pwd == query.pwd { 422 | anyHit = true 423 | samePwd = true 424 | score += actualPwdScore 425 | } else if sameGitRepo { 426 | anyHit = true 427 | score += sameGitRepoScore 428 | } 429 | 430 | differentHost := false 431 | if record.Host != query.host { 432 | differentHost = true 433 | score -= differentHostScorePenalty 434 | } 435 | // errorExitStatus := false 436 | if record.ExitCode != 0 { 437 | // errorExitStatus = true 438 | score -= nonZeroExitCodeScorePenalty 439 | } 440 | _ = anyHit 441 | // if score <= 0 && !anyHit { 442 | // return Item{}, errors.New("no match for given record and query") 443 | // } 444 | score += record.Time * timeScoreCoef 445 | 446 | it := Item{ 447 | time: record.Time, 448 | 449 | differentHost: differentHost, 450 | host: record.Host, 451 | home: record.Home, 452 | samePwd: samePwd, 453 | pwd: record.Pwd, 454 | 455 | sameGitRepo: sameGitRepo, 456 | exitCode: record.ExitCode, 457 | CmdLineOut: record.CmdLine, 458 | CmdLine: cmdLine, 459 | CmdLineWithColor: cmdLineWithColor, 460 | Score: score, 461 | Key: key, 462 | } 463 | return it, nil 464 | } 465 | 466 | // GetHeader returns header columns 467 | func GetHeader(compactRendering bool) ItemColumns { 468 | date := "TIME " 469 | host := "HOST" 470 | dir := "DIRECTORY" 471 | if compactRendering { 472 | dir = "DIR" 473 | } 474 | flags := " FLAGS" 475 | cmdLine := "COMMAND-LINE" 476 | return ItemColumns{ 477 | Date: date, 478 | DateWithColor: date, 479 | Host: host, 480 | PwdTilde: dir, 481 | samePwd: false, 482 | Flags: flags, 483 | FlagsWithColor: flags, 484 | CmdLine: cmdLine, 485 | CmdLineWithColor: cmdLine, 486 | // score: i.score, 487 | Key: "_HEADERS_", 488 | } 489 | } 490 | 491 | // RawItem is item for raw mode 492 | type RawItem struct { 493 | CmdLineWithColor string 494 | CmdLine string 495 | // Unchanged cmdline to paste to command line 496 | CmdLineOut string 497 | 498 | Score float64 499 | 500 | Key string 501 | // cmdLineRaw string 502 | } 503 | 504 | // NewRawItemFromRecordForQuery creates new item from record based on given query 505 | // 506 | // returns error if the query doesn't match the record 507 | func NewRawItemFromRecordForQuery(record recordint.SearchApp, terms []string, debug bool) (RawItem, error) { 508 | const hitScore = 1.0 509 | const hitScoreConsecutive = 0.01 510 | const properMatchScore = 0.3 511 | 512 | const timeScoreCoef = 1e-13 513 | 514 | // Trim trailing whitespace before highlighting 515 | trimmedCmdLine := strings.TrimRightFunc(record.CmdLine, unicode.IsSpace) 516 | 517 | // KEY for deduplication 518 | key := trimmedCmdLine 519 | 520 | score := 0.0 521 | cmd := trimmedCmdLine 522 | for _, term := range terms { 523 | c := strings.Count(record.CmdLine, term) 524 | if c > 0 { 525 | score += hitScore + hitScoreConsecutive*float64(c) 526 | if properMatch(cmd, term, " ") { 527 | score += properMatchScore 528 | } 529 | cmd = strings.ReplaceAll(cmd, term, highlightMatch(term)) 530 | } 531 | } 532 | score += record.Time * timeScoreCoef 533 | // DISPLAY > cmdline 534 | 535 | // cmd := "<" + strings.ReplaceAll(record.CmdLine, "\n", ";") + ">" 536 | cmdLine := replaceNewLines(trimmedCmdLine) 537 | cmdLineWithColor := replaceNewLines(cmd) 538 | 539 | it := RawItem{ 540 | CmdLineOut: record.CmdLine, 541 | CmdLine: cmdLine, 542 | CmdLineWithColor: cmdLineWithColor, 543 | Score: score, 544 | Key: key, 545 | } 546 | return it, nil 547 | } 548 | -------------------------------------------------------------------------------- /internal/searchapp/item_test.go: -------------------------------------------------------------------------------- 1 | package searchapp 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // TestLeftCutPadString 8 | func TestLeftCutPadString(t *testing.T) { 9 | if leftCutPadString("abc", -1) != "" { 10 | t.Fatal("Incorrect left cut from abc to '' (negative)") 11 | } 12 | if leftCutPadString("abc", 0) != "" { 13 | t.Fatal("Incorrect left cut from abc to ''") 14 | } 15 | if leftCutPadString("abc", 1) != "…" { 16 | t.Fatal("Incorrect left cut from abc to …") 17 | } 18 | if leftCutPadString("abc", 2) != "…c" { 19 | t.Fatal("Incorrect left cut from abc to …c") 20 | } 21 | if leftCutPadString("abc", 3) != "abc" { 22 | t.Fatal("Incorrect left cut from abc to abc") 23 | } 24 | if leftCutPadString("abc", 5) != " abc" { 25 | t.Fatal("Incorrect left pad from abc to ' abc'") 26 | } 27 | 28 | // unicode 29 | if leftCutPadString("♥♥♥♥", -1) != "" { 30 | t.Fatal("Incorrect left cut from ♥♥♥♥ to '' (negative)") 31 | } 32 | if leftCutPadString("♥♥♥♥", 0) != "" { 33 | t.Fatal("Incorrect left cut from ♥♥♥♥ to ''") 34 | } 35 | if leftCutPadString("♥♥♥♥", 1) != "…" { 36 | t.Fatal("Incorrect left cut from ♥♥♥♥ to …") 37 | } 38 | if leftCutPadString("♥♥♥♥", 2) != "…♥" { 39 | t.Fatal("Incorrect left cut from ♥♥♥♥ to …♥") 40 | } 41 | if leftCutPadString("♥♥♥♥", 4) != "♥♥♥♥" { 42 | t.Fatal("Incorrect left cut from ♥♥♥♥ to ♥♥♥♥") 43 | } 44 | if leftCutPadString("♥♥♥♥", 6) != " ♥♥♥♥" { 45 | t.Fatal("Incorrect left pad from ♥♥♥♥ to ' ♥♥♥♥'") 46 | } 47 | } 48 | 49 | // TestRightCutPadString 50 | func TestRightCutPadString(t *testing.T) { 51 | if rightCutPadString("abc", -1) != "" { 52 | t.Fatal("Incorrect right cut from abc to '' (negative)") 53 | } 54 | if rightCutPadString("abc", 0) != "" { 55 | t.Fatal("Incorrect right cut from abc to ''") 56 | } 57 | if rightCutPadString("abc", 1) != "…" { 58 | t.Fatal("Incorrect right cut from abc to …") 59 | } 60 | if rightCutPadString("abc", 2) != "a…" { 61 | t.Fatal("Incorrect right cut from abc to a…") 62 | } 63 | if rightCutPadString("abc", 3) != "abc" { 64 | t.Fatal("Incorrect right cut from abc to abc") 65 | } 66 | if rightCutPadString("abc", 5) != "abc " { 67 | t.Fatal("Incorrect right pad from abc to 'abc '") 68 | } 69 | 70 | // unicode 71 | if rightCutPadString("♥♥♥♥", -1) != "" { 72 | t.Fatal("Incorrect right cut from ♥♥♥♥ to '' (negative)") 73 | } 74 | if rightCutPadString("♥♥♥♥", 0) != "" { 75 | t.Fatal("Incorrect right cut from ♥♥♥♥ to ''") 76 | } 77 | if rightCutPadString("♥♥♥♥", 1) != "…" { 78 | t.Fatal("Incorrect right cut from ♥♥♥♥ to …") 79 | } 80 | if rightCutPadString("♥♥♥♥", 2) != "♥…" { 81 | t.Fatal("Incorrect right cut from ♥♥♥♥ to ♥…") 82 | } 83 | if rightCutPadString("♥♥♥♥", 4) != "♥♥♥♥" { 84 | t.Fatal("Incorrect right cut from ♥♥♥♥ to ♥♥♥♥") 85 | } 86 | if rightCutPadString("♥♥♥♥", 6) != "♥♥♥♥ " { 87 | t.Fatal("Incorrect right pad from ♥♥♥♥ to '♥♥♥♥ '") 88 | } 89 | } 90 | 91 | // TestRightCutLeftPadString 92 | func TestRightCutLeftPadString(t *testing.T) { 93 | if rightCutLeftPadString("abc", -1) != "" { 94 | t.Fatal("Incorrect right cut from abc to '' (negative)") 95 | } 96 | if rightCutLeftPadString("abc", 0) != "" { 97 | t.Fatal("Incorrect right cut from abc to ''") 98 | } 99 | if rightCutLeftPadString("abc", 1) != "…" { 100 | t.Fatal("Incorrect right cut from abc to …") 101 | } 102 | if rightCutLeftPadString("abc", 2) != "a…" { 103 | t.Fatal("Incorrect right cut from abc to a…") 104 | } 105 | if rightCutLeftPadString("abc", 3) != "abc" { 106 | t.Fatal("Incorrect right cut from abc to abc") 107 | } 108 | if rightCutLeftPadString("abc", 5) != " abc" { 109 | t.Fatal("Incorrect right pad from abc to ' abc'") 110 | } 111 | 112 | // unicode 113 | if rightCutLeftPadString("♥♥♥♥", -1) != "" { 114 | t.Fatal("Incorrect right cut from ♥♥♥♥ to '' (negative)") 115 | } 116 | if rightCutLeftPadString("♥♥♥♥", 0) != "" { 117 | t.Fatal("Incorrect right cut from ♥♥♥♥ to ''") 118 | } 119 | if rightCutLeftPadString("♥♥♥♥", 1) != "…" { 120 | t.Fatal("Incorrect right cut from ♥♥♥♥ to …") 121 | } 122 | if rightCutLeftPadString("♥♥♥♥", 2) != "♥…" { 123 | t.Fatal("Incorrect right cut from ♥♥♥♥ to ♥…") 124 | } 125 | if rightCutLeftPadString("♥♥♥♥", 4) != "♥♥♥♥" { 126 | t.Fatal("Incorrect right cut from ♥♥♥♥ to ♥♥♥♥") 127 | } 128 | if rightCutLeftPadString("♥♥♥♥", 6) != " ♥♥♥♥" { 129 | t.Fatal("Incorrect right pad from ♥♥♥♥ to ' ♥♥♥♥'") 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /internal/searchapp/query.go: -------------------------------------------------------------------------------- 1 | package searchapp 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | "github.com/curusarn/resh/internal/normalize" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | // Query holds information that is used for result scoring 12 | type Query struct { 13 | terms []string 14 | host string 15 | pwd string 16 | gitOriginRemote string 17 | // pwdTilde string 18 | } 19 | 20 | func isValidTerm(term string) bool { 21 | if len(term) == 0 { 22 | return false 23 | } 24 | if strings.Contains(term, " ") { 25 | return false 26 | } 27 | return true 28 | } 29 | 30 | func filterTerms(terms []string) []string { 31 | var newTerms []string 32 | for _, term := range terms { 33 | if isValidTerm(term) { 34 | newTerms = append(newTerms, term) 35 | } 36 | } 37 | return newTerms 38 | } 39 | 40 | // NewQueryFromString . 41 | func NewQueryFromString(sugar *zap.SugaredLogger, queryInput string, host string, pwd string, gitOriginRemote string, debug bool) Query { 42 | terms := strings.Fields(queryInput) 43 | var logStr string 44 | for _, term := range terms { 45 | logStr += " <" + term + ">" 46 | } 47 | terms = filterTerms(terms) 48 | logStr = "" 49 | for _, term := range terms { 50 | logStr += " <" + term + ">" 51 | } 52 | sort.SliceStable(terms, func(i, j int) bool { return len(terms[i]) < len(terms[j]) }) 53 | return Query{ 54 | terms: terms, 55 | host: host, 56 | pwd: pwd, 57 | gitOriginRemote: normalize.GitRemote(sugar, gitOriginRemote), 58 | } 59 | } 60 | 61 | // GetRawTermsFromString . 62 | func GetRawTermsFromString(queryInput string, debug bool) []string { 63 | terms := strings.Fields(queryInput) 64 | var logStr string 65 | for _, term := range terms { 66 | logStr += " <" + term + ">" 67 | } 68 | terms = filterTerms(terms) 69 | logStr = "" 70 | for _, term := range terms { 71 | logStr += " <" + term + ">" 72 | } 73 | return terms 74 | } 75 | -------------------------------------------------------------------------------- /internal/searchapp/time.go: -------------------------------------------------------------------------------- 1 | package searchapp 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | func formatTimeRelativeLongest(tm time.Time) string { 9 | tmSince := time.Since(tm) 10 | hrs := tmSince.Hours() 11 | yrs := int(hrs / (365 * 24)) 12 | if yrs > 0 { 13 | if yrs == 1 { 14 | return "1 year ago" 15 | } 16 | return strconv.Itoa(yrs) + " years ago" 17 | } 18 | months := int(hrs / (30 * 24)) 19 | if months > 0 { 20 | if months == 1 { 21 | return "1 month ago" 22 | } 23 | return strconv.Itoa(months) + " months ago" 24 | } 25 | days := int(hrs / 24) 26 | if days > 0 { 27 | if days == 1 { 28 | return "1 day ago" 29 | } 30 | return strconv.Itoa(days) + " days ago" 31 | } 32 | hrsInt := int(hrs) 33 | if hrsInt > 0 { 34 | if hrsInt == 1 { 35 | return "1 hour ago" 36 | } 37 | return strconv.Itoa(hrsInt) + " hours ago" 38 | } 39 | mins := int(hrs*60) % 60 40 | if mins > 0 { 41 | if mins == 1 { 42 | return "1 min ago" 43 | } 44 | return strconv.Itoa(mins) + " mins ago" 45 | } 46 | secs := int(hrs*60*60) % 60 47 | if secs > 0 { 48 | if secs == 1 { 49 | return "1 sec ago" 50 | } 51 | return strconv.Itoa(secs) + " secs ago" 52 | } 53 | return "now" 54 | } 55 | 56 | func formatTimeRelativeLong(tm time.Time) string { 57 | tmSince := time.Since(tm) 58 | hrs := tmSince.Hours() 59 | yrs := int(hrs / (365 * 24)) 60 | if yrs > 0 { 61 | if yrs == 1 { 62 | return "1 year" 63 | } 64 | return strconv.Itoa(yrs) + " years" 65 | } 66 | months := int(hrs / (30 * 24)) 67 | if months > 0 { 68 | if months == 1 { 69 | return "1 month" 70 | } 71 | return strconv.Itoa(months) + " months" 72 | } 73 | days := int(hrs / 24) 74 | if days > 0 { 75 | if days == 1 { 76 | return "1 day" 77 | } 78 | return strconv.Itoa(days) + " days" 79 | } 80 | hrsInt := int(hrs) 81 | if hrsInt > 0 { 82 | if hrsInt == 1 { 83 | return "1 hour" 84 | } 85 | return strconv.Itoa(hrsInt) + " hours" 86 | } 87 | mins := int(hrs*60) % 60 88 | if mins > 0 { 89 | if mins == 1 { 90 | return "1 min" 91 | } 92 | return strconv.Itoa(mins) + " mins" 93 | } 94 | secs := int(hrs*60*60) % 60 95 | if secs > 0 { 96 | if secs == 1 { 97 | return "1 sec" 98 | } 99 | return strconv.Itoa(secs) + " secs" 100 | } 101 | return "now" 102 | } 103 | 104 | func formatTimeMixedLongest(tm time.Time) string { 105 | tmSince := time.Since(tm) 106 | hrs := tmSince.Hours() 107 | yrs := int(hrs / (365 * 24)) 108 | if yrs > 0 { 109 | if yrs == 1 { 110 | return "1 year ago" 111 | } 112 | return strconv.Itoa(yrs) + " years ago" 113 | } 114 | months := int(hrs / (30 * 24)) 115 | if months > 0 { 116 | if months == 1 { 117 | return "1 month ago" 118 | } 119 | return strconv.Itoa(months) + " months ago" 120 | } 121 | days := int(hrs / 24) 122 | if days > 0 { 123 | if days == 1 { 124 | return "1 day ago" 125 | } 126 | return strconv.Itoa(days) + " days ago" 127 | } 128 | hrsInt := int(hrs) 129 | mins := int(hrs*60) % 60 130 | return strconv.Itoa(hrsInt) + ":" + strconv.Itoa(mins) 131 | } 132 | 133 | func formatTimeRelativeShort(tm time.Time) string { 134 | tmSince := time.Since(tm) 135 | hrs := tmSince.Hours() 136 | yrs := int(hrs / (365 * 24)) 137 | if yrs > 0 { 138 | return strconv.Itoa(yrs) + " Y" 139 | } 140 | months := int(hrs / (30 * 24)) 141 | if months > 0 { 142 | return strconv.Itoa(months) + " M" 143 | } 144 | days := int(hrs / 24) 145 | if days > 0 { 146 | return strconv.Itoa(days) + " D" 147 | } 148 | hrsInt := int(hrs) 149 | if hrsInt > 0 { 150 | return strconv.Itoa(hrsInt) + " h" 151 | } 152 | mins := int(hrs*60) % 60 153 | if mins > 0 { 154 | return strconv.Itoa(mins) + " m" 155 | } 156 | secs := int(hrs*60*60) % 60 157 | if secs > 0 { 158 | return strconv.Itoa(secs) + " s" 159 | } 160 | return "now" 161 | } 162 | 163 | func formatTimeMixedShort(tm time.Time) string { 164 | tmSince := time.Since(tm) 165 | hrs := tmSince.Hours() 166 | yrs := int(hrs / (365 * 24)) 167 | if yrs > 0 { 168 | return strconv.Itoa(yrs) + " Y" 169 | } 170 | months := int(hrs / (30 * 24)) 171 | if months > 0 { 172 | return strconv.Itoa(months) + " M" 173 | } 174 | days := int(hrs / 24) 175 | if days > 0 { 176 | return strconv.Itoa(days) + " D" 177 | } 178 | hrsInt := int(hrs) 179 | mins := int(hrs*60) % 60 180 | return strconv.Itoa(hrsInt) + ":" + strconv.Itoa(mins) 181 | } 182 | -------------------------------------------------------------------------------- /internal/sess/sess.go: -------------------------------------------------------------------------------- 1 | package sess 2 | 3 | // Session represents a session, used for sennding through channels when more than just ID is needed 4 | type Session struct { 5 | ID string 6 | PID int 7 | } 8 | -------------------------------------------------------------------------------- /internal/sesswatch/sesswatch.go: -------------------------------------------------------------------------------- 1 | package sesswatch 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/curusarn/resh/internal/recordint" 8 | "github.com/mitchellh/go-ps" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | type sesswatch struct { 13 | sugar *zap.SugaredLogger 14 | 15 | sessionsToDrop []chan string 16 | sleepSeconds uint 17 | 18 | watchedSessions map[string]bool 19 | mutex sync.Mutex 20 | } 21 | 22 | // Go runs the session watcher - watches sessions and sends 23 | func Go(sugar *zap.SugaredLogger, 24 | sessionsToWatch chan recordint.SessionInit, sessionsToWatchRecords chan recordint.Collect, 25 | sessionsToDrop []chan string, sleepSeconds uint) { 26 | 27 | sw := sesswatch{ 28 | sugar: sugar.With("module", "sesswatch"), 29 | sessionsToDrop: sessionsToDrop, 30 | sleepSeconds: sleepSeconds, 31 | watchedSessions: map[string]bool{}, 32 | } 33 | go sw.waiter(sessionsToWatch, sessionsToWatchRecords) 34 | } 35 | 36 | func (s *sesswatch) waiter(sessionsToWatch chan recordint.SessionInit, sessionsToWatchRecords chan recordint.Collect) { 37 | for { 38 | func() { 39 | select { 40 | case rec := <-sessionsToWatch: 41 | // normal way to start watching a session 42 | id := rec.SessionID 43 | pid := rec.SessionPID 44 | sugar := s.sugar.With( 45 | "sessionID", rec.SessionID, 46 | "sessionPID", rec.SessionPID, 47 | ) 48 | s.mutex.Lock() 49 | defer s.mutex.Unlock() 50 | if s.watchedSessions[id] == false { 51 | sugar.Infow("Starting watching new session") 52 | s.watchedSessions[id] = true 53 | go s.watcher(sugar, id, pid) 54 | } 55 | case rec := <-sessionsToWatchRecords: 56 | // additional safety - watch sessions that were never properly initialized 57 | id := rec.SessionID 58 | pid := rec.SessionPID 59 | sugar := s.sugar.With( 60 | "sessionID", rec.SessionID, 61 | "sessionPID", rec.SessionPID, 62 | ) 63 | s.mutex.Lock() 64 | defer s.mutex.Unlock() 65 | if s.watchedSessions[id] == false { 66 | sugar.Warnw("Starting watching new session based on '/record'") 67 | s.watchedSessions[id] = true 68 | go s.watcher(sugar, id, pid) 69 | } 70 | } 71 | }() 72 | } 73 | } 74 | 75 | func (s *sesswatch) watcher(sugar *zap.SugaredLogger, sessionID string, sessionPID int) { 76 | for { 77 | time.Sleep(time.Duration(s.sleepSeconds) * time.Second) 78 | proc, err := ps.FindProcess(sessionPID) 79 | if err != nil { 80 | sugar.Errorw("Error while finding process", "error", err) 81 | } else if proc == nil { 82 | sugar.Infow("Dropping session") 83 | func() { 84 | s.mutex.Lock() 85 | defer s.mutex.Unlock() 86 | s.watchedSessions[sessionID] = false 87 | }() 88 | for _, ch := range s.sessionsToDrop { 89 | sugar.Debugw("Sending 'drop session' message ...") 90 | ch <- sessionID 91 | sugar.Debugw("Sending 'drop session' message DONE") 92 | } 93 | break 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /internal/signalhandler/signalhander.go: -------------------------------------------------------------------------------- 1 | package signalhandler 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "os/signal" 8 | "strconv" 9 | "syscall" 10 | "time" 11 | 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func sendSignals(sugar *zap.SugaredLogger, sig os.Signal, subscribers []chan os.Signal, done chan string) { 16 | for _, sub := range subscribers { 17 | sub <- sig 18 | } 19 | sugar.Warnw("Sent shutdown signals to components") 20 | chanCount := len(subscribers) 21 | start := time.Now() 22 | delay := time.Millisecond * 100 23 | timeout := time.Millisecond * 2000 24 | 25 | for { 26 | select { 27 | case _ = <-done: 28 | chanCount-- 29 | if chanCount == 0 { 30 | sugar.Warnw("All components shut down successfully") 31 | return 32 | } 33 | default: 34 | time.Sleep(delay) 35 | } 36 | if time.Since(start) > timeout { 37 | sugar.Errorw("Timeouted while waiting for proper shutdown", 38 | "componentsStillUp", strconv.Itoa(chanCount), 39 | "timeout", timeout.String(), 40 | ) 41 | return 42 | } 43 | } 44 | } 45 | 46 | // Run catches and handles signals 47 | func Run(sugar *zap.SugaredLogger, subscribers []chan os.Signal, done chan string, server *http.Server) { 48 | sugar = sugar.With("module", "signalhandler") 49 | signals := make(chan os.Signal, 1) 50 | 51 | signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP) 52 | 53 | var sig os.Signal 54 | for { 55 | sig := <-signals 56 | sugarSig := sugar.With("signal", sig.String()) 57 | sugarSig.Infow("Got signal") 58 | if sig == syscall.SIGTERM { 59 | // Shutdown daemon on SIGTERM 60 | break 61 | } 62 | sugarSig.Warnw("Ignoring signal. Send SIGTERM to trigger shutdown.") 63 | } 64 | 65 | sugar.Infow("Sending shutdown signals to components ...") 66 | sendSignals(sugar, sig, subscribers, done) 67 | 68 | sugar.Infow("Shutting down the server ...") 69 | if err := server.Shutdown(context.Background()); err != nil { 70 | sugar.Errorw("Error while shuting down HTTP server", 71 | "error", err, 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/curusarn/resh/internal/httpclient" 11 | "github.com/curusarn/resh/internal/msg" 12 | ) 13 | 14 | func get(port int) (*http.Response, error) { 15 | url := "http://localhost:" + strconv.Itoa(port) + "/status" 16 | client := httpclient.New() 17 | resp, err := client.Get(url) 18 | if err != nil { 19 | return nil, fmt.Errorf("error while GET'ing daemon /status: %w", err) 20 | } 21 | return resp, nil 22 | } 23 | 24 | func IsDaemonRunning(port int) (bool, error) { 25 | resp, err := get(port) 26 | if err != nil { 27 | return false, err 28 | } 29 | defer resp.Body.Close() 30 | return true, nil 31 | } 32 | 33 | func GetDaemonStatus(port int) (*msg.StatusResponse, error) { 34 | resp, err := get(port) 35 | if err != nil { 36 | return nil, err 37 | } 38 | defer resp.Body.Close() 39 | jsn, err := io.ReadAll(resp.Body) 40 | if err != nil { 41 | return nil, fmt.Errorf("error while reading 'daemon /status' response: %w", err) 42 | } 43 | var msgResp msg.StatusResponse 44 | err = json.Unmarshal(jsn, &msgResp) 45 | if err != nil { 46 | return nil, fmt.Errorf("error while decoding 'daemon /status' response: %w", err) 47 | } 48 | return &msgResp, nil 49 | } 50 | -------------------------------------------------------------------------------- /record/legacy.go: -------------------------------------------------------------------------------- 1 | package record 2 | 3 | type Legacy struct { 4 | // core 5 | CmdLine string `json:"cmdLine"` 6 | ExitCode int `json:"exitCode"` 7 | Shell string `json:"shell"` 8 | Uname string `json:"uname"` 9 | SessionID string `json:"sessionId"` 10 | RecordID string `json:"recordId"` 11 | 12 | // posix 13 | Home string `json:"home"` 14 | Lang string `json:"lang"` 15 | LcAll string `json:"lcAll"` 16 | Login string `json:"login"` 17 | Pwd string `json:"pwd"` 18 | PwdAfter string `json:"pwdAfter"` 19 | ShellEnv string `json:"shellEnv"` 20 | Term string `json:"term"` 21 | 22 | // non-posix"` 23 | RealPwd string `json:"realPwd"` 24 | RealPwdAfter string `json:"realPwdAfter"` 25 | Pid int `json:"pid"` 26 | SessionPID int `json:"sessionPid"` 27 | Host string `json:"host"` 28 | Hosttype string `json:"hosttype"` 29 | Ostype string `json:"ostype"` 30 | Machtype string `json:"machtype"` 31 | Shlvl int `json:"shlvl"` 32 | 33 | // before after 34 | TimezoneBefore string `json:"timezoneBefore"` 35 | TimezoneAfter string `json:"timezoneAfter"` 36 | 37 | RealtimeBefore float64 `json:"realtimeBefore"` 38 | RealtimeAfter float64 `json:"realtimeAfter"` 39 | RealtimeBeforeLocal float64 `json:"realtimeBeforeLocal"` 40 | RealtimeAfterLocal float64 `json:"realtimeAfterLocal"` 41 | 42 | RealtimeDuration float64 `json:"realtimeDuration"` 43 | RealtimeSinceSessionStart float64 `json:"realtimeSinceSessionStart"` 44 | RealtimeSinceBoot float64 `json:"realtimeSinceBoot"` 45 | 46 | GitDir string `json:"gitDir"` 47 | GitRealDir string `json:"gitRealDir"` 48 | GitOriginRemote string `json:"gitOriginRemote"` 49 | GitDirAfter string `json:"gitDirAfter"` 50 | GitRealDirAfter string `json:"gitRealDirAfter"` 51 | GitOriginRemoteAfter string `json:"gitOriginRemoteAfter"` 52 | MachineID string `json:"machineId"` 53 | 54 | OsReleaseID string `json:"osReleaseId"` 55 | OsReleaseVersionID string `json:"osReleaseVersionId"` 56 | OsReleaseIDLike string `json:"osReleaseIdLike"` 57 | OsReleaseName string `json:"osReleaseName"` 58 | OsReleasePrettyName string `json:"osReleasePrettyName"` 59 | 60 | ReshUUID string `json:"reshUuid"` 61 | ReshVersion string `json:"reshVersion"` 62 | ReshRevision string `json:"reshRevision"` 63 | 64 | // records come in two parts (collect and postcollect) 65 | PartOne bool `json:"partOne,omitempty"` // false => part two 66 | PartsMerged bool `json:"partsMerged"` 67 | // special flag -> not an actual record but an session end 68 | SessionExit bool `json:"sessionExit,omitempty"` 69 | 70 | // recall metadata 71 | Recalled bool `json:"recalled"` 72 | RecallHistno int `json:"recallHistno,omitempty"` 73 | RecallStrategy string `json:"recallStrategy,omitempty"` 74 | RecallActionsRaw string `json:"recallActionsRaw,omitempty"` 75 | RecallActions []string `json:"recallActions,omitempty"` 76 | RecallLastCmdLine string `json:"recallLastCmdLine"` 77 | 78 | // recall command 79 | RecallPrefix string `json:"recallPrefix,omitempty"` 80 | 81 | // added by sanitizatizer 82 | Sanitized bool `json:"sanitized,omitempty"` 83 | CmdLength int `json:"cmdLength,omitempty"` 84 | 85 | // fields that are string here and int in older resh verisons 86 | Cols interface{} `json:"cols"` 87 | Lines interface{} `json:"lines"` 88 | } 89 | -------------------------------------------------------------------------------- /record/record.go: -------------------------------------------------------------------------------- 1 | // Package record provides record types that are used in resh history files 2 | package record 3 | -------------------------------------------------------------------------------- /record/v1.go: -------------------------------------------------------------------------------- 1 | package record 2 | 3 | type V1 struct { 4 | // future-proofing so that we can add this later without version bump 5 | // deleted, favorite 6 | Deleted bool `json:"deleted,omitempty"` 7 | Favorite bool `json:"favorite,omitempty"` 8 | 9 | // cmdline, exitcode 10 | CmdLine string `json:"cmdLine"` 11 | ExitCode int `json:"exitCode"` 12 | 13 | DeviceID string `json:"deviceID"` 14 | SessionID string `json:"sessionID"` 15 | // can we have a shorter uuid for record 16 | RecordID string `json:"recordID"` 17 | 18 | // paths 19 | // TODO: Do we need both pwd and real pwd? 20 | Home string `json:"home"` 21 | Pwd string `json:"pwd"` 22 | RealPwd string `json:"realPwd"` 23 | 24 | // Device is set during installation/setup 25 | // It is stored in RESH configuration 26 | Device string `json:"device"` 27 | 28 | // git info 29 | // origin is the most important 30 | GitOriginRemote string `json:"gitOriginRemote"` 31 | // TODO: add GitBranch (v2 ?) 32 | // maybe branch could be useful - e.g. in monorepo ?? 33 | // GitBranch string `json:"gitBranch"` 34 | 35 | // what is this for ?? 36 | // session watching needs this 37 | // but I'm not sure if we need to save it 38 | // records belong to sessions 39 | // PID int `json:"pid"` 40 | // needed for tracking of sessions but I think it shouldn't be part of V1 41 | // SessionPID int `json:"sessionPID"` 42 | 43 | // needed to because records are merged with parts with same "SessionID + Shlvl" 44 | // I don't think we need to save it 45 | // Shlvl int `json:"shlvl"` 46 | 47 | // time (before), duration of command 48 | // time and duration are strings because we don't want unnecessary precision when they get serialized into json 49 | // we could implement custom (un)marshalling but I don't see downsides of directly representing the values as strings 50 | Time string `json:"time"` 51 | Duration string `json:"duration"` 52 | 53 | // these look like internal stuff 54 | 55 | // TODO: rethink 56 | // I don't really like this :/ 57 | // Maybe just one field 'NotMerged' with 'partOne' and 'partTwo' as values and empty string for merged 58 | // records come in two parts (collect and postcollect) 59 | PartOne bool `json:"partOne,omitempty"` // false => part two 60 | PartsNotMerged bool `json:"partsNotMerged,omitempty"` 61 | 62 | // special flag -> not an actual record but an session end 63 | // TODO: this shouldn't be part of serializable V1 record 64 | SessionExit bool `json:"sessionExit,omitempty"` 65 | } 66 | -------------------------------------------------------------------------------- /scripts/hooks.sh: -------------------------------------------------------------------------------- 1 | #!/hint/sh 2 | 3 | # BACKWARDS COMPATIBILITY NOTES: 4 | # 5 | # Stable names and options: 6 | # * `resh-collect -version` / `resh-postcollect -version` is used to detect version mismatch. 7 | # => The go-like/short `-version` option needs to exist for new resh-(post)collect commands in all future version. 8 | # => Prefer using go-like/short `-version` option so that we don't have more options to support indefinitely. 9 | # * `__resh_preexec ` with `__RESH_NO_RELOAD=1` is called on version mismatch. 10 | # => The `__resh_preexec` function needs to exist in all future versions. 11 | # => Make sure that `__RESH_NO_RELOAD` behavior is not broken in any future version. 12 | # => Prefer only testing `__RESH_NO_RELOAD` for emptyness instead of specific value 13 | # * `__resh_reload_msg` is called *after* shell files reload 14 | # => The function shows a message from the already updated shell files 15 | # => We can drop this function at any time - the old version will be used 16 | 17 | # Backwards compatibilty: Please see notes above before making any changes here. 18 | __resh_reload_msg() { 19 | printf '\n' 20 | printf '┌──────────────────────────────────────────────────────────────┐\n' 21 | printf '│ New version of RESH shell files was loaded in this terminal. │\n' 22 | printf '│ This is an informative message - no action is necessary. │\n' 23 | printf '│ Please restart this terminal if you encounter any issues. │\n' 24 | printf '└──────────────────────────────────────────────────────────────┘\n' 25 | printf '\n' 26 | } 27 | 28 | # (pre)collect 29 | # Backwards compatibilty: Please see notes above before making any changes here. 30 | __resh_preexec() { 31 | if [ "$(resh-collect -version)" != "$__RESH_VERSION" ] && [ -z "${__RESH_NO_RELOAD-}" ]; then 32 | # Reload shell files and restart __resh_preexec - i.e. the full command will be recorded only with a slight delay. 33 | # This should happens in every already open terminal after resh update. 34 | # __RESH_NO_RELOAD prevents recursive reloads 35 | # We never repeatadly reload the shell files to prevent potentially infinite recursion. 36 | # If the version is still wrong then error will be raised by `resh-collect -requiredVersion`. 37 | 38 | source ~/.resh/shellrc 39 | # Show reload message from the updated shell files 40 | __resh_reload_msg 41 | # Rerun self but prevent another reload. Extra protection against infinite recursion. 42 | __RESH_NO_RELOAD=1 __resh_preexec "$@" 43 | return $? 44 | fi 45 | __RESH_COLLECT=1 46 | __RESH_RECORD_ID=$(resh-generate-uuid) 47 | # TODO: do this in resh-collect 48 | # shellcheck disable=2155 49 | local git_remote="$(git remote get-url origin 2>/dev/null)" 50 | # TODO: do this in resh-collect 51 | __RESH_RT_BEFORE=$(resh-get-epochtime) 52 | resh-collect -requireVersion "$__RESH_VERSION" \ 53 | --git-remote "$git_remote" \ 54 | --home "$HOME" \ 55 | --pwd "$PWD" \ 56 | --record-id "$__RESH_RECORD_ID" \ 57 | --session-id "$__RESH_SESSION_ID" \ 58 | --session-pid "$$" \ 59 | --shell "$__RESH_SHELL" \ 60 | --shlvl "$SHLVL" \ 61 | --time "$__RESH_RT_BEFORE" \ 62 | --cmd-line "$1" 63 | return $? 64 | } 65 | 66 | # postcollect 67 | # Backwards compatibilty: Please see notes above before making any changes here. 68 | __resh_precmd() { 69 | # Get status first before it gets overriden by another command. 70 | local exit_code=$? 71 | # Don't do anything if __resh_preexec was not called. 72 | # There are situations (in bash) where no command was submitted but __resh_precmd gets called anyway. 73 | [ -n "${__RESH_COLLECT-}" ] || return 74 | if [ "$(resh-postcollect -version)" != "$__RESH_VERSION" ]; then 75 | # Reload shell files and return - i.e. skip recording part2 for this command. 76 | # We don't call __resh_precmd because the new __resh_preexec might not be backwards compatible with variables set by old __resh_preexec. 77 | # This should happen only in the one terminal where resh update was executed. 78 | # And the resh-daemon was likely restarted so we likely don't even have the matching part1 of the comand in the resh-daemon memory. 79 | source ~/.resh/shellrc 80 | # Show reload message from the updated shell files 81 | __resh_reload_msg 82 | return 83 | fi 84 | unset __RESH_COLLECT 85 | 86 | # do this in resh-postcollect 87 | # shellcheck disable=2155 88 | local rt_after=$(resh-get-epochtime) 89 | resh-postcollect -requireVersion "$__RESH_VERSION" \ 90 | --exit-code "$exit_code" \ 91 | --record-id "$__RESH_RECORD_ID" \ 92 | --session-id "$__RESH_SESSION_ID" \ 93 | --shlvl "$SHLVL" \ 94 | --time-after "$rt_after" \ 95 | --time-before "$__RESH_RT_BEFORE" 96 | return $? 97 | } 98 | 99 | # Backwards compatibilty: No restrictions. This is only used at the start of the session. 100 | __resh_session_init() { 101 | resh-session-init -requireVersion "$__RESH_VERSION" \ 102 | --session-id "$__RESH_SESSION_ID" \ 103 | --session-pid "$$" 104 | return $? 105 | } 106 | 107 | # Backwards compatibilty: Please see notes above before making any changes here. 108 | __resh_widget_control_R() { 109 | # This is a very bad workaround. 110 | # Force bash-preexec to run repeatedly because otherwise premature run of bash-preexec overshadows the next proper run. 111 | # I honestly think that it's impossible to make widgets work in bash without hacks like this. 112 | # shellcheck disable=2034 113 | __bp_preexec_interactive_mode="on" 114 | 115 | local PREVBUFFER=$BUFFER 116 | 117 | local status_code 118 | local git_remote; git_remote="$(git remote get-url origin 2>/dev/null)" 119 | if [ "$(resh-cli -version)" != "$__RESH_VERSION" ] && [ -z "${__RESH_NO_RELOAD-}" ]; then 120 | source ~/.resh/shellrc 121 | # Show reload message from the updated shell files 122 | __resh_reload_msg 123 | # Rerun self but prevent another reload. Extra protection against infinite recursion. 124 | __RESH_NO_RELOAD=1 __resh_widget_control_R "$@" 125 | return $? 126 | fi 127 | BUFFER=$(resh-cli -requireVersion "$__RESH_VERSION" \ 128 | --git-remote "$git_remote" \ 129 | --pwd "$PWD" \ 130 | --query "$BUFFER" \ 131 | --session-id "$__RESH_SESSION_ID" \ 132 | ) 133 | status_code=$? 134 | if [ $status_code = 111 ]; then 135 | # execute 136 | if [ -n "${ZSH_VERSION-}" ]; then 137 | # zsh 138 | zle accept-line 139 | elif [ -n "${BASH_VERSION-}" ]; then 140 | # bash 141 | # set chained keyseq to accept-line 142 | bind '"\u[32~": accept-line' 143 | fi 144 | elif [ $status_code = 0 ]; then 145 | if [ -n "${BASH_VERSION-}" ]; then 146 | # bash 147 | # set chained keyseq to nothing 148 | bind -x '"\u[32~": __resh_nop' 149 | fi 150 | else 151 | echo "RESH SEARCH APP failed" 152 | printf "%s" "$buffer" >&2 153 | BUFFER="$PREVBUFFER" 154 | fi 155 | CURSOR=${#BUFFER} 156 | } 157 | 158 | # Wrapper for resh-cli for calling resh directly 159 | resh() { 160 | if [ "$(resh-cli -version)" != "$__RESH_VERSION" ] && [ -z "${__RESH_NO_RELOAD-}" ]; then 161 | source ~/.resh/shellrc 162 | # Show reload message from the updated shell files 163 | __resh_reload_msg 164 | # Rerun self but prevent another reload. Extra protection against infinite recursion. 165 | __RESH_NO_RELOAD=1 resh "$@" 166 | return $? 167 | fi 168 | local buffer 169 | local git_remote; git_remote="$(git remote get-url origin 2>/dev/null)" 170 | buffer=$(resh-cli -requireVersion "$__RESH_VERSION" \ 171 | --git-remote "$git_remote" \ 172 | --pwd "$PWD" \ 173 | --session-id "$__RESH_SESSION_ID" \ 174 | "$@" 175 | ) 176 | status_code=$? 177 | if [ $status_code = 111 ]; then 178 | # execute 179 | echo "$buffer" 180 | eval "$buffer" 181 | elif [ $status_code = 0 ]; then 182 | # paste 183 | echo "$buffer" 184 | elif [ $status_code = 130 ]; then 185 | true 186 | else 187 | printf "%s" "$buffer" >&2 188 | fi 189 | } 190 | 191 | __resh_widget_control_R_compat() { 192 | __bindfunc_compat_wrapper __resh_widget_control_R 193 | } 194 | 195 | __resh_nop() { 196 | # does nothing 197 | true 198 | } 199 | 200 | # shellcheck source=../submodules/bash-zsh-compat-widgets/bindfunc.sh 201 | . ~/.resh/bindfunc.sh 202 | 203 | __resh_bind_control_R() { 204 | bindfunc '\C-r' __resh_widget_control_R_compat 205 | if [ -n "${BASH_VERSION-}" ]; then 206 | # fuck bash 207 | bind '"\C-r": "\u[31~\u[32~"' 208 | bind -x '"\u[31~": __resh_widget_control_R_compat' 209 | 210 | # execute 211 | # bind '"\u[32~": accept-line' 212 | 213 | # just paste 214 | # bind -x '"\u[32~": __resh_nop' 215 | true 216 | fi 217 | return 0 218 | } 219 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | # TODO: There is a lot of hardcoded stuff here (paths mostly) 6 | # TODO: Split this into installation and setup because we want to suport package manager installation eventually 7 | # TODO: "installation" should stay here and be simple, "setup" should be moved behind "reshctl setup" 8 | 9 | echo 10 | echo "Checking your system ..." 11 | printf '\e[31;1m' # red color on 12 | 13 | cleanup() { 14 | printf '\e[0m' # reset 15 | exit 16 | } 17 | trap cleanup EXIT INT TERM 18 | 19 | # /usr/bin/zsh -> zsh 20 | login_shell=$(echo "$SHELL" | rev | cut -d'/' -f1 | rev) 21 | 22 | if [ "$login_shell" != bash ] && [ "$login_shell" != zsh ]; then 23 | echo "* UNSUPPORTED login shell: $login_shell" 24 | echo " -> RESH supports zsh and bash" 25 | echo 26 | if [ -z "${RESH_INSTALL_IGNORE_LOGIN_SHELL-}" ]; then 27 | echo 'EXITING!' 28 | echo ' -> You can skip this check with `export RESH_INSTALL_IGNORE_LOGIN_SHELL=1`' 29 | exit 1 30 | fi 31 | fi 32 | 33 | # TODO: Explicitly ask users if they want to enable RESH in shells 34 | # Only offer shells with supported versions 35 | # E.g. Enable RESH in: Zsh (your login shell), Bash, Both shells 36 | 37 | bash_version=$(bash -c 'echo ${BASH_VERSION}') 38 | bash_version_major=$(bash -c 'echo ${BASH_VERSINFO[0]}') 39 | bash_version_minor=$(bash -c 'echo ${BASH_VERSINFO[1]}') 40 | bash_ok=1 41 | if [ "$bash_version_major" -le 3 ]; then 42 | bash_ok=0 43 | elif [ "$bash_version_major" -eq 4 ] && [ "$bash_version_minor" -lt 3 ]; then 44 | bash_ok=0 45 | fi 46 | 47 | if [ "$bash_ok" != 1 ]; then 48 | echo "* UNSUPPORTED bash version: $bash_version" 49 | echo " -> Update to bash 4.3+ if you want to use RESH in bash" 50 | echo 51 | fi 52 | 53 | zsh_ok=1 54 | if ! zsh --version >/dev/null 2>&1; then 55 | echo "* Zsh not installed" 56 | zsh_ok=0 57 | else 58 | zsh_version=$(zsh -c 'echo ${ZSH_VERSION}') 59 | zsh_version_major=$(echo "$zsh_version" | cut -d'.' -f1) 60 | if [ "$zsh_version_major" -lt 5 ]; then 61 | echo "* UNSUPPORTED zsh version: $zsh_version" 62 | echo " -> Updatie to zsh 5.0+ if you want to use RESH in zsh" 63 | echo 64 | zsh_ok=0 65 | fi 66 | fi 67 | 68 | if [ "$bash_ok" != 1 ] && [ "$zsh_ok" != 1 ]; then 69 | echo "* You have no shell that is supported by RESH!" 70 | echo " -> Please install/update zsh or bash and run this installation again" 71 | echo 72 | if [ -z "${RESH_INSTALL_IGNORE_NO_SHELL-}" ]; then 73 | echo 'EXITING!' 74 | echo ' -> You can prevent this check by setting `export RESH_INSTALL_IGNORE_NO_SHELL=1`' 75 | echo 76 | exit 1 77 | fi 78 | fi 79 | 80 | printf '\e[0m' # reset 81 | # echo "Continue with installation? (Any key to CONTINUE / Ctrl+C to ABORT)" 82 | # # shellcheck disable=2034 83 | # read -r x 84 | 85 | if [ -z "${__RESH_VERSION-}" ]; then 86 | # First installation 87 | # Stop the daemon anyway just to be sure 88 | # But don't output anything 89 | ./scripts/resh-daemon-stop.sh -q ||: 90 | else 91 | ./scripts/resh-daemon-stop.sh ||: 92 | fi 93 | 94 | echo "Installing ..." 95 | 96 | # Crete dirs first to get rid of edge-cases 97 | # If we fail we don't roll back - directories are harmless 98 | mkdir_if_not_exists() { 99 | if [ ! -d "$1" ]; then 100 | mkdir "$1" 101 | fi 102 | } 103 | 104 | mkdir_if_not_exists ~/.resh 105 | mkdir_if_not_exists ~/.resh/bin 106 | mkdir_if_not_exists ~/.config 107 | 108 | # Run setup and update tasks 109 | 110 | ./bin/resh-install-utils setup-device 111 | # migrate-all updates format of config and history 112 | # migrate-all restores original config and history on error 113 | # There is no need to roll back anything else because we haven't replaced 114 | # anything in the previous installation. 115 | ./bin/resh-install-utils migrate-all 116 | 117 | 118 | # Copy files 119 | 120 | # echo "Copying files ..." 121 | cp -f submodules/bash-preexec/bash-preexec.sh ~/.bash-preexec.sh 122 | cp -f submodules/bash-zsh-compat-widgets/bindfunc.sh ~/.resh/bindfunc.sh 123 | 124 | cp -f scripts/shellrc.sh ~/.resh/shellrc 125 | cp -f scripts/resh-daemon-start.sh ~/.resh/bin/resh-daemon-start 126 | cp -f scripts/resh-daemon-stop.sh ~/.resh/bin/resh-daemon-stop 127 | cp -f scripts/resh-daemon-restart.sh ~/.resh/bin/resh-daemon-restart 128 | cp -f scripts/hooks.sh ~/.resh/ 129 | cp -f scripts/rawinstall.sh ~/.resh/ 130 | 131 | # echo "Copying more files ..." 132 | # Copy all go executables. We don't really need to omit install-utils from the bin directory 133 | cp -f bin/resh-* ~/.resh/bin/ 134 | # Rename reshctl 135 | mv ~/.resh/bin/resh-control ~/.resh/bin/reshctl 136 | 137 | 138 | echo "Handling shell files ..." 139 | # Only add shell directives into bash if it passed version checks 140 | if [ "$bash_ok" = 1 ]; then 141 | if [ ! -f ~/.bashrc ]; then 142 | touch ~/.bashrc 143 | fi 144 | # Adding resh shellrc to .bashrc ... 145 | grep -q '[[ -f ~/.resh/shellrc ]] && source ~/.resh/shellrc' ~/.bashrc ||\ 146 | echo -e '\n[[ -f ~/.resh/shellrc ]] && source ~/.resh/shellrc # this line was added by RESH' >> ~/.bashrc 147 | # Adding bash-preexec to .bashrc ... 148 | grep -q '[[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh' ~/.bashrc ||\ 149 | echo -e '\n[[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh # this line was added by RESH' >> ~/.bashrc 150 | fi 151 | 152 | # Only add shell directives into zsh if it passed version checks 153 | if [ "$zsh_ok" = 1 ]; then 154 | # Adding resh shellrc to .zshrc ... 155 | if [ -f ~/.zshrc ]; then 156 | grep -q '[ -f ~/.resh/shellrc ] && source ~/.resh/shellrc' ~/.zshrc ||\ 157 | echo -e '\n[ -f ~/.resh/shellrc ] && source ~/.resh/shellrc # this line was added by RESH' >> ~/.zshrc 158 | fi 159 | fi 160 | 161 | ~/.resh/bin/resh-daemon-start 162 | 163 | # bright green 164 | high='\e[1m' 165 | reset='\e[0m' 166 | 167 | printf ' 168 | Installation finished successfully. 169 | 170 | 171 | QUICK START 172 | \e[32;1m Press CTRL+R to launch RESH SEARCH \e[0m 173 | ' 174 | if [ -z "${__RESH_VERSION-}" ]; then 175 | printf 'You will need to restart your terminal first!\n' 176 | fi 177 | printf ' 178 | Full-text search your shell history. 179 | Relevant results are displayed first based on current directory, git repo, and exit status. 180 | 181 | RESH will locally record and save shell history with context (directory, time, exit status, ...) 182 | Start using RESH right away because bash and zsh history are also searched. 183 | 184 | Update RESH by running: reshctl update 185 | Thank you for using RESH! 186 | ' 187 | 188 | # Show banner if RESH is not loaded in the terminal 189 | if [ -z "${__RESH_VERSION-}" ]; then printf ' 190 | ┌────────────────────────────────────────────────────────────┐ 191 | │ │ 192 | │ Finish the installation by RESTARTING this terminal! │ 193 | │ │ 194 | └────────────────────────────────────────────────────────────┘ 195 | ' 196 | fi 197 | -------------------------------------------------------------------------------- /scripts/rawinstall.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | echo 6 | echo "Please report any issues you encounter to: https://github.com/curusarn/resh/issues" 7 | echo 8 | 9 | if [ "${1-}" = "--beta" ] || [ "${1-}" = "-b" ]; then 10 | echo "Looking for the latest release or PRERELEASE (because you used --beta flag) ..." 11 | # debug 12 | # latest release OR pre-release 13 | json=$(curl --silent "https://api.github.com/repos/curusarn/resh/releases") 14 | tag=$(echo "$json" | grep '"tag_name":' | cut -d':' -f2 | tr -d ',' | cut -d'"' -f2 | head -n 1) 15 | else 16 | echo "Looking for the latest release ..." 17 | # latest release 18 | json=$(curl --silent "https://api.github.com/repos/curusarn/resh/releases/latest") 19 | # not very robust but we don't want any dependencies to parse to JSON 20 | tag=$(echo "$json" | grep '"tag_name":' | cut -d':' -f2 | tr -d ',' | cut -d'"' -f2) 21 | fi 22 | 23 | if [ ${#tag} -lt 2 ]; then 24 | echo "ERROR: Couldn't determine the latest release! (extracted git tag is too short \"${tag}\")" 25 | exit 1 26 | fi 27 | if [ "${tag:0:1}" != v ]; then 28 | echo "ERROR: Couldn't determine the latest release! (extracted git tag doesn't start with 'v' \"${tag}\")" 29 | exit 1 30 | fi 31 | version="${tag:1}" 32 | # TODO: check if version is numeric 33 | 34 | echo " * Latest version: $version (git tag: $tag)" 35 | 36 | # TODO: compare semanitcally instead of just using equality 37 | if [ "${__RESH_VERSION:-}" == "$version" ]; then 38 | echo " * RESH is up to date - nothing to do - exiting." 39 | exit 0 40 | fi 41 | 42 | echo 43 | 44 | if [ "$(uname)" = "Darwin" ]; then 45 | OS=darwin 46 | elif [ "$(uname)" = "Linux" ]; then 47 | OS=linux 48 | else 49 | OS=unknown 50 | fi 51 | 52 | case "$(uname -m)" in 53 | x86_64) 54 | ARCH=amd64 55 | ;; 56 | x86|i386|i686) 57 | ARCH=386 58 | ;; 59 | arm|armv7*) 60 | ARCH=armv6 61 | ;; 62 | arm64|aarch64*|armv8*) 63 | ARCH=arm64 64 | ;; 65 | *) 66 | ARCH=unknown 67 | ;; 68 | esac 69 | 70 | if [ "$OS" = darwin ]; then 71 | if [ "$ARCH" = armv6 ] ; then 72 | echo "It seems that you are running macOS on 32-bit arm - exiting!" 73 | echo "Expected Linux or macOS on x86_64, i386, or arm64 (or Linux on 32-bit arm)" 74 | echo "Got OS: $(uname) (uname)" 75 | echo "Got ARCH: $(uname -m) (uname -m)" 76 | exit 1 77 | fi 78 | fi 79 | 80 | if [ "$OS" = unknown ] || [ "$ARCH" = unknown ]; then 81 | echo "Couldn't detect your OS and architecture - exiting!" 82 | echo "Expected Linux or macOS on x86_64, i386, or arm64 (or Linux on 32-bit arm)" 83 | echo "Got OS: $(uname) (uname)" 84 | echo "Got ARCH: $(uname -m) (uname -m)" 85 | exit 1 86 | fi 87 | 88 | dl_base="https://github.com/curusarn/resh/releases/download/${tag}" 89 | 90 | fname_checksums="resh_${version}_checksums.txt" 91 | dl_checksums="$dl_base/$fname_checksums" 92 | 93 | fname_binaries="resh_${version}_${OS}_${ARCH}.tar.gz" 94 | dl_binaries="$dl_base/$fname_binaries" 95 | 96 | 97 | tmpdir="$(mktemp -d /tmp/resh-rawinstall-XXXXXXXX)" 98 | # echo 99 | # echo "Changing to $tmpdir ..." 100 | cd "$tmpdir" 101 | 102 | echo "Downloading files ..." 103 | 104 | curl_opt="--location --remote-name --progress-bar" 105 | 106 | echo " * $fname_checksums" 107 | # shellcheck disable=2086 108 | COLUMNS=80 curl $curl_opt "$dl_checksums" 109 | 110 | echo " * $fname_binaries" 111 | # shellcheck disable=2086 112 | COLUMNS=80 curl $curl_opt "$dl_binaries" 113 | 114 | # TODO: check if we downloaded anything 115 | # Github serves you a "Not found" page so the curl doesn't error out 116 | 117 | echo 118 | echo "Checking integrity ..." 119 | 120 | # macOS doesn't have sha256sum 121 | if [ "$OS" = darwin ]; then 122 | function sha256sum() { shasum -a 256 "$@" ; } && export -f sha256sum 123 | fi 124 | 125 | if [ "$(sha256sum "$fname_binaries")" != "$(grep "$fname_binaries" "$fname_checksums")" ]; then 126 | echo "ERROR: integrity check failed - exiting!" 127 | exit 1 128 | fi 129 | echo " * OK" 130 | 131 | echo 132 | echo "Extracting downloaded files ..." 133 | tar -xzf "$fname_binaries" 134 | echo " * OK" 135 | 136 | if ! scripts/install.sh; then 137 | if [ $? != 130 ]; then 138 | echo 139 | printf '\e[31;1m' # red color on 140 | printf '┌────────────────────────────┐\n' 141 | printf '│ │\n' 142 | printf '│ INSTALLATION FAILED! │\n' 143 | printf '│ │\n' 144 | printf '└────────────────────────────┘\n' 145 | printf '\e[0m' # reset 146 | echo 147 | echo "Please create an issue: https://github.com/curusarn/resh/issues" 148 | fi 149 | echo 150 | echo "Rerun the installation and skip downloading by running:" 151 | echo 152 | echo "cd $PWD && scripts/install.sh" 153 | echo 154 | exit 1 155 | fi 156 | 157 | -------------------------------------------------------------------------------- /scripts/resh-daemon-restart.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | resh-daemon-stop "$@" 3 | resh-daemon-start "$@" -------------------------------------------------------------------------------- /scripts/resh-daemon-start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | if [ "${1-}" != "-q" ]; then 3 | echo "Starting RESH daemon ..." 4 | printf "Logs are in: %s\n" "${XDG_DATA_HOME-~/.local/share}/resh/log.json" 5 | fi 6 | # Run daemon in background - don't block 7 | # Redirect stdin, stdout, and stderr to /dev/null - detach all I/O 8 | resh-daemon /dev/null 2>/dev/null & 9 | 10 | # After resh-daemon-start.sh exits the resh-daemon process loses its parent 11 | # and it gets adopted by init 12 | 13 | # NOTES: 14 | # No disown - job control of this shell doesn't affect the parent shell 15 | # No nohup - SIGHUP signals won't be sent to orphaned resh-daemon (plus the daemon ignores them) 16 | # No setsid - SIGINT signals won't be sent to orphaned resh-daemon (plus the daemon ignores them) -------------------------------------------------------------------------------- /scripts/resh-daemon-stop.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -eu 3 | 4 | q=0 5 | [ "${1-}" != "-q" ] || q=1 6 | 7 | xdg_pid() { 8 | local path="${XDG_DATA_HOME-}"/resh/daemon.pid 9 | [ -n "${XDG_DATA_HOME-}" ] && [ -f "$path" ] || return 1 10 | cat "$path" 11 | rm "$path" 12 | } 13 | default_pid() { 14 | local path=~/.local/share/resh/daemon.pid 15 | [ -f "$path" ] || return 1 16 | cat "$path" 17 | rm "$path" 18 | } 19 | 20 | kill_by_pid() { 21 | [ -n "$1" ] || return 1 22 | [ "$q" = "1" ] || printf "Stopping RESH daemon ... (PID: %s)\n" "$1" 23 | kill "$1" 24 | } 25 | kill_by_name() { 26 | [ "$q" = "1" ] || printf "Stopping RESH daemon ...\n" 27 | killall -q resh-daemon 28 | } 29 | failed_to_kill() { 30 | [ "$q" = "1" ] || echo "Failed to kill the RESH daemon - it probably isn't running" 31 | return 1 32 | } 33 | 34 | kill_by_pid "$(xdg_pid)" || kill_by_pid "$(default_pid)" || kill_by_name || failed_to_kill 35 | -------------------------------------------------------------------------------- /scripts/shellrc.sh: -------------------------------------------------------------------------------- 1 | #!/hint/sh 2 | 3 | PATH=$PATH:~/.resh/bin 4 | 5 | # shellcheck source=hooks.sh 6 | . ~/.resh/hooks.sh 7 | 8 | if [ -n "${ZSH_VERSION-}" ]; then 9 | # shellcheck disable=SC1009 10 | __RESH_SHELL="zsh" 11 | elif [ -n "${BASH_VERSION-}" ]; then 12 | __RESH_SHELL="bash" 13 | else 14 | echo "RESH PANIC: unrecognized shell - please report this to https://github.com/curusarn/resh/issues" 15 | fi 16 | 17 | # shellcheck disable=2155 18 | export __RESH_VERSION=$(resh-collect -version) 19 | 20 | resh-daemon-start -q 21 | 22 | [ "$(resh-config --key BindControlR)" = true ] && __resh_bind_control_R 23 | 24 | # block for anything we only want to do once per session 25 | # NOTE: nested shells are still the same session 26 | # i.e. $__RESH_SESSION_ID will be set in nested shells 27 | if [ -z "${__RESH_SESSION_ID+x}" ]; then 28 | # shellcheck disable=2155 29 | export __RESH_SESSION_ID=$(resh-generate-uuid) 30 | 31 | __resh_session_init 32 | fi 33 | 34 | # block for anything we only want to do once per shell 35 | # NOTE: nested shells are new shells 36 | # i.e. $__RESH_INIT_DONE will NOT be set in nested shells 37 | if [ -z "${__RESH_INIT_DONE+x}" ]; then 38 | preexec_functions+=(__resh_preexec) 39 | precmd_functions+=(__resh_precmd) 40 | 41 | __RESH_INIT_DONE=1 42 | fi -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # very simple tests to catch simple errors in scripts 3 | 4 | for f in scripts/*.sh; do 5 | echo "Running shellcheck on $f ..." 6 | shellcheck "$f" --shell=sh --severity=error || exit 1 7 | done 8 | 9 | for f in scripts/{shellrc,hooks}.sh; do 10 | echo "Checking Zsh syntax of $f ..." 11 | ! zsh -n "$f" && echo "Zsh syntax check failed!" && exit 1 12 | done 13 | 14 | if [ "$1" = "--all" ]; then 15 | for sh in bash zsh; do 16 | echo "Running functions in scripts/shellrc.sh using $sh ..." 17 | ! $sh -c ". scripts/shellrc.sh; __resh_preexec; __resh_precmd" && echo "Error while running functions!" && exit 1 18 | done 19 | fi 20 | 21 | # TODO: test installation 22 | 23 | exit 0 24 | -------------------------------------------------------------------------------- /troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## First help 4 | 5 | Run RESH doctor to detect common issues: 6 | ```sh 7 | reshctl doctor 8 | ``` 9 | 10 | ## Restarting RESH daemon 11 | 12 | Sometimes restarting RESH daemon can help: 13 | ```sh 14 | resh-daemon-restart 15 | ``` 16 | 17 | You can also start and stop RESH daemon with: 18 | ```sh 19 | resh-daemon-start 20 | resh-daemon-stop 21 | ``` 22 | 23 | :warning: You will get error messages in your shell when RESH daemon is not running. 24 | 25 | ## Recorded history 26 | 27 | Your RESH history is saved in one of: 28 | - `~/.local/share/resh/history.reshjson` 29 | - `$XDG_DATA_HOME/resh/history.reshjson` 30 | 31 | Each line is one JSON record prefixed by version. Display it as JSON using: 32 | 33 | ```sh 34 | cat ~/.local/share/resh/history.reshjson | sed 's/^v[^{]*{/{/' | jq . 35 | ``` 36 | 37 | ℹ️ You will need `jq` installed. 38 | 39 | ## Configuration 40 | 41 | RESH config is read from one of: 42 | - `~/.config/resh.toml` 43 | - `$XDG_CONFIG_HOME/resh.toml` 44 | 45 | ## Logs 46 | 47 | Logs can be useful for troubleshooting issues. 48 | 49 | Find RESH logs in one of: 50 | - `~/.local/share/resh/log.json` 51 | - `$XDG_DATA_HOME/resh/log.json` 52 | 53 | ### Log verbosity 54 | 55 | Get more detailed logs by setting `LogLevel = "debug"` in [RESH config](#configuration). 56 | Restart RESH daemon for the config change to take effect: `resh-daemon-restart` 57 | 58 | ## Common problems 59 | 60 | ### Using RESH with bash on macOS 61 | 62 | ℹ️ It is recommended to use zsh on macOS. 63 | 64 | MacOS comes with really old bash (`bash 3.2`). 65 | Update it using: `brew install bash` 66 | 67 | On macOS, bash shell does not load `~/.bashrc` because every shell runs as login shell. 68 | Fix it by running: `echo '[ -f ~/.bashrc ] && . ~/.bashrc' >> ~/.bash_profile` 69 | 70 | ## GitHub issues 71 | 72 | Problem persists? [Create an issue ⇗](https://github.com/curusarn/resh/issues) 73 | --------------------------------------------------------------------------------