├── .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 | [](https://github.com/curusarn/resh/releases)
3 | [](https://goreportcard.com/report/github.com/curusarn/resh)
4 | [](https://github.com/curusarn/resh/actions/workflows/go.yaml)
5 | [](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 |
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 |
--------------------------------------------------------------------------------