├── .editorconfig ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build.yml │ └── release.yaml ├── .gitignore ├── .goreleaser.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── assets └── logo.svg ├── browser ├── browser.go ├── browser_darwin.go ├── browser_linux.go ├── browser_windows.go ├── errors.go ├── flags.go └── options.go ├── cmd ├── browser.go ├── config.go ├── exec.go ├── update.go └── version.go ├── config ├── context.go ├── errors.go ├── flags.go ├── init.go └── store.go ├── ferret └── main.go ├── go.mod ├── go.sum ├── install.sh ├── internal └── selfupdate │ ├── github.go │ └── updater.go ├── logger ├── level.go ├── logger.go └── options.go ├── repl └── repl.go ├── revive.toml ├── runtime ├── builtin.go ├── options.go ├── remote.go └── runtime.go └── versions.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.md] 2 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [] 4 | patreon: ziflex 5 | open_collective: ferret -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | analyze: 7 | name: Static Analysis 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v4 13 | 14 | - name: Set up Go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: '>=1.24' 18 | 19 | - name: Set up linters 20 | run: make install-tools 21 | 22 | - name: Lint 23 | run: | 24 | make vet 25 | make lint 26 | make fmt 27 | git diff 28 | if [[ $(git diff) != '' ]]; then echo 'Invalid formatting!' >&2; exit 1; fi 29 | 30 | build: 31 | name: Build 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Check out code into the Go module directory 35 | uses: actions/checkout@v4 36 | 37 | - name: Set up Go 38 | uses: actions/setup-go@v5 39 | with: 40 | go-version: '>=1.24' 41 | id: go 42 | 43 | - name: Get dependencies 44 | run: make install 45 | 46 | - name: Compile 47 | run: make compile -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | goreleaser: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: '>=1.24' 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - name: Set up env vars 25 | run: | 26 | export FERRET_VERSION=$(sh versions.sh ferret) 27 | echo "FERRET_VERSION=$FERRET_VERSION" >> $GITHUB_ENV 28 | - name: Run GoReleaser 29 | uses: goreleaser/goreleaser-action@v6 30 | with: 31 | version: latest 32 | args: release --clean 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### macOS template 3 | # General 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | ### Go template 31 | # Binaries for programs and plugins 32 | *.exe 33 | *.exe~ 34 | *.dll 35 | *.so 36 | *.dylib 37 | 38 | # Test binary, built with `go test -c` 39 | *.test 40 | 41 | # Output of the go coverage tool, specifically when used with LiteIDE 42 | *.out 43 | 44 | # Dependency directories (remove the comment below to include it) 45 | # vendor/ 46 | 47 | ### Linux template 48 | *~ 49 | 50 | # temporary files which can be created if a process still has a handle open of a deleted file 51 | .fuse_hidden* 52 | 53 | # KDE directory preferences 54 | .directory 55 | 56 | # Linux trash folder which might appear on any partition or disk 57 | .Trash-* 58 | 59 | # .nfs files are created when an open file is removed but is still being accessed 60 | .nfs* 61 | 62 | ### Windows template 63 | # Windows thumbnail cache files 64 | Thumbs.db 65 | Thumbs.db:encryptable 66 | ehthumbs.db 67 | ehthumbs_vista.db 68 | 69 | # Dump file 70 | *.stackdump 71 | 72 | # Folder config file 73 | [Dd]esktop.ini 74 | 75 | # Recycle Bin used on file shares 76 | $RECYCLE.BIN/ 77 | 78 | # Windows Installer files 79 | *.cab 80 | *.msi 81 | *.msix 82 | *.msm 83 | *.msp 84 | 85 | # Windows shortcuts 86 | *.lnk 87 | 88 | # JetBrains 89 | .idea/ 90 | 91 | # Project 92 | bin/ 93 | dist/ 94 | 95 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | version: 2 4 | 5 | before: 6 | hooks: 7 | - go mod download 8 | 9 | builds: 10 | - 11 | # Path to main.go file or main package. 12 | # Default is `.`. 13 | main: ./ferret/main.go 14 | # Binary name. 15 | # Can be a path (e.g. `bin/app`) to wrap the binary in a directory. 16 | # Default is the name of the project directory. 17 | binary: ferret 18 | env: 19 | - CGO_ENABLED=0 20 | goos: 21 | - linux 22 | - darwin 23 | - windows 24 | goarch: 25 | - amd64 26 | - arm64 27 | ldflags: 28 | - -s -w -X main.version={{ .Version }} 29 | - -s -w -X github.com/MontFerret/cli/runtime.version={{ .Env.FERRET_VERSION }} 30 | 31 | archives: 32 | - name_template: >- 33 | {{- .ProjectName }}_ 34 | {{- .Os }}_ 35 | {{- if eq .Arch "amd64" }}x86_64 36 | {{- else if eq .Arch "386" }}i386 37 | {{- else }}{{ .Arch }}{{ end }} 38 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 39 | 40 | format_overrides: 41 | - goos: windows 42 | formats: ['zip'] 43 | 44 | checksum: 45 | name_template: '{{ .ProjectName }}_checksums.txt' 46 | 47 | snapshot: 48 | version_template: "{{ .Tag }}-next" 49 | 50 | changelog: 51 | sort: asc 52 | filters: 53 | exclude: 54 | - '^docs:' 55 | - '^test:' -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MontFerret/cli/b7902f3335c8f1201e84143c5e85819156a533d0/CHANGELOG.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION ?= $(shell sh versions.sh cli) 2 | FERRET_VERSION = $(shell sh versions.sh ferret) 3 | DIR_BIN = ./bin 4 | NAME = ferret 5 | 6 | default: build 7 | 8 | build: vet lint test compile 9 | 10 | install-tools: 11 | go install honnef.co/go/tools/cmd/staticcheck@latest && \ 12 | go install golang.org/x/tools/cmd/goimports@latest && \ 13 | go install github.com/mgechev/revive@latest 14 | 15 | install: 16 | go mod download 17 | 18 | compile: 19 | go build -v -o ${DIR_BIN}/${NAME} \ 20 | -ldflags "-X main.version=${VERSION} -X github.com/MontFerret/cli/runtime.version=${FERRET_VERSION}" \ 21 | ./ferret/main.go 22 | 23 | test: 24 | go test ./... 25 | 26 | fmt: 27 | go fmt ./... && \ 28 | goimports -w -local github.com/MontFerret ./browser ./cmd ./config ./ferret ./internal ./logger ./repl ./runtime 29 | 30 | lint: 31 | staticcheck ./... && \ 32 | revive -config revive.toml -formatter stylish -exclude ./pkg/parser/fql/... -exclude ./vendor/... ./... 33 | 34 | 35 | vet: 36 | go vet ./... -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ferret CLI 2 | 3 |

4 | 5 | Go Report Status 6 | 7 | 8 | Build Status 9 | 10 | 11 | Discord Chat 12 | 13 | 14 | Ferret release 15 | 16 | 17 | Apache-2.0 License 18 | 19 |

20 | 21 |

22 | lab 23 |

24 | 25 | Documentation is available [at our website](https://www.montferret.dev/docs/introduction/). 26 | 27 | ## Installation 28 | 29 | ### Binary 30 | 31 | You can download the latest binaries from [here](https://github.com/MontFerret/cli/releases). 32 | 33 | ### Source (Go >= 1.18) 34 | ```bash 35 | go install github.com/MontFerret/cli/ferret@latest 36 | ``` 37 | 38 | ### Shell 39 | ```shell 40 | curl https://raw.githubusercontent.com/MontFerret/cli/master/install.sh | sh 41 | ``` 42 | 43 | ## Quick start 44 | 45 | ### REPL 46 | 47 | ```bash 48 | ferret exec 49 | Welcome to Ferret REPL 50 | 51 | Please use `exit` or `Ctrl-D` to exit this program. 52 | ``` 53 | 54 | ### Script execution 55 | ```bash 56 | ferret exec my-script.fql 57 | ``` 58 | 59 | ### With browser 60 | 61 | ```bash 62 | ferret exec --browser-open my-script.fql 63 | ``` 64 | 65 | #### As headless 66 | 67 | ```bash 68 | ferret exec --browser-headless my-script.fql 69 | ``` 70 | 71 | ### Query parameters 72 | 73 | ```bash 74 | ferret exec -p 'foo:"bar"' -p 'qaz:"baz"' my-script.fql 75 | ``` 76 | 77 | ### With remote runtime (worker) 78 | 79 | ```bash 80 | ferret exec --runtime 'https://my-worker.com' my-script.fql 81 | ``` 82 | 83 | ## Options 84 | 85 | ```bash 86 | Usage: 87 | ferret [flags] 88 | ferret [command] 89 | 90 | Available Commands: 91 | browser Manage Ferret browsers 92 | config Manage Ferret configs 93 | exec Execute a FQL script or launch REPL 94 | help Help about any command 95 | selfupdate Update Ferret CLI 96 | version Show the CLI version information 97 | 98 | Flags: 99 | -h, --help help for ferret 100 | -l, --log-level string Set the logging level ("debug"|"info"|"warn"|"error"|"fatal") (default "info") 101 | 102 | Use "ferret [command] --help" for more information about a command. 103 | 104 | ``` 105 | 106 | ## Contributors 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /browser/browser.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/go-waitfor/waitfor" 7 | http "github.com/go-waitfor/waitfor-http" 8 | ) 9 | 10 | type Browser interface { 11 | Open(ctx context.Context) (uint64, error) 12 | 13 | Close(ctx context.Context, pid uint64) error 14 | } 15 | 16 | func Open(ctx context.Context, opts Options) (uint64, error) { 17 | b := New(opts) 18 | 19 | pid, err := b.Open(ctx) 20 | 21 | if err != nil { 22 | return 0, err 23 | } 24 | 25 | if opts.Detach { 26 | return pid, Wait(ctx, opts) 27 | } 28 | 29 | return pid, nil 30 | } 31 | 32 | func Wait(ctx context.Context, opts Options) error { 33 | runner := waitfor.New(http.Use()) 34 | 35 | return runner.Test(ctx, []string{ 36 | opts.ToURL(), 37 | }, waitfor.WithAttempts(10)) 38 | } 39 | 40 | func Close(ctx context.Context, opts Options, pid uint64) error { 41 | b := New(opts) 42 | 43 | return b.Close(ctx, pid) 44 | } 45 | -------------------------------------------------------------------------------- /browser/browser_darwin.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | type DarwinBrowser struct { 15 | opts Options 16 | } 17 | 18 | func New(opts Options) Browser { 19 | return &DarwinBrowser{opts} 20 | } 21 | 22 | func (b *DarwinBrowser) Open(ctx context.Context) (uint64, error) { 23 | path, err := b.findBinaryPath() 24 | 25 | if err != nil { 26 | return 0, err 27 | } 28 | 29 | cmd := exec.CommandContext(ctx, path, b.opts.ToFlags()...) 30 | 31 | if b.opts.Detach { 32 | if err := cmd.Start(); err != nil { 33 | return 0, err 34 | } 35 | 36 | return uint64(cmd.Process.Pid), nil 37 | } 38 | 39 | cmd.Stdout = os.Stdout 40 | cmd.Stderr = os.Stdout 41 | 42 | return 0, cmd.Run() 43 | } 44 | 45 | func (b *DarwinBrowser) Close(ctx context.Context, pid uint64) error { 46 | if pid > 0 { 47 | if err := exec.Command("kill", fmt.Sprintf("%d", pid)).Run(); err != nil { 48 | return ErrProcNotFound 49 | } 50 | } 51 | 52 | binaryPath, err := b.findBinaryPath() 53 | 54 | if err != nil { 55 | return err 56 | } 57 | 58 | cmdStr := fmt.Sprintf("%s %s", binaryPath, strings.Join(b.opts.ToFlags(), " ")) 59 | 60 | psOut, err := exec.Command("ps", "-o", "pid=", "-o", "command=").Output() 61 | 62 | if err != nil { 63 | return ErrProcNotFound 64 | } 65 | 66 | r := regexp.MustCompile(`(\d+)\s(.+)`) 67 | 68 | for _, pair := range r.FindAllStringSubmatch(string(psOut), -1) { 69 | cmd := pair[2] 70 | 71 | if strings.HasPrefix(cmd, cmdStr) { 72 | p, err := strconv.ParseUint(pair[1], 10, 64) 73 | 74 | if err == nil { 75 | pid = p 76 | break 77 | } 78 | } 79 | } 80 | 81 | if pid == 0 { 82 | return ErrProcNotFound 83 | } 84 | 85 | return exec.CommandContext(ctx, "kill", fmt.Sprintf("%d", pid)).Run() 86 | } 87 | 88 | func (b *DarwinBrowser) findBinaryPath() (string, error) { 89 | variants := []string{ 90 | "Google Chrome", 91 | "Google Chrome Canary", 92 | "Chromium", 93 | "Chromium Canary", 94 | } 95 | 96 | var result string 97 | 98 | // Find an installed one 99 | for _, name := range variants { 100 | dir := filepath.Join("/Applications", fmt.Sprintf("%s.app", name)) 101 | stat, err := os.Stat(dir) 102 | 103 | if err == nil && stat.IsDir() { 104 | result = filepath.Join(dir, "Contents/MacOS", name) 105 | 106 | break 107 | } 108 | } 109 | 110 | if result == "" { 111 | return "", ErrBinNotFound 112 | } 113 | 114 | return result, nil 115 | } 116 | -------------------------------------------------------------------------------- /browser/browser_linux.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | type LinuxBrowser struct { 14 | opts Options 15 | } 16 | 17 | func New(opts Options) Browser { 18 | return &LinuxBrowser{opts} 19 | } 20 | 21 | func (b *LinuxBrowser) Open(ctx context.Context) (uint64, error) { 22 | path, err := b.findBinaryPath() 23 | 24 | if err != nil { 25 | return 0, err 26 | } 27 | 28 | cmd := exec.CommandContext(ctx, path, b.opts.ToFlags()...) 29 | 30 | if b.opts.Detach { 31 | if err := cmd.Start(); err != nil { 32 | return 0, err 33 | } 34 | 35 | return uint64(cmd.Process.Pid), nil 36 | } 37 | 38 | cmd.Stdout = os.Stdout 39 | cmd.Stderr = os.Stdout 40 | 41 | return 0, cmd.Run() 42 | } 43 | 44 | func (b *LinuxBrowser) Close(ctx context.Context, pid uint64) error { 45 | if pid > 0 { 46 | if err := exec.Command("kill", fmt.Sprintf("%d", pid)).Run(); err != nil { 47 | return ErrProcNotFound 48 | } 49 | } 50 | 51 | cmdStr := strings.Join(b.opts.ToFlags(), " ") 52 | 53 | psOut, err := exec.Command("ps", "-o", "pid=", "-o", "command=").Output() 54 | 55 | if err != nil { 56 | return ErrProcNotFound 57 | } 58 | 59 | r := regexp.MustCompile(`(\d+)\s(.+)`) 60 | 61 | for _, pair := range r.FindAllStringSubmatch(string(psOut), -1) { 62 | cmd := strings.TrimSpace(pair[2]) 63 | 64 | if strings.HasSuffix(cmd, cmdStr) { 65 | p, err := strconv.ParseUint(pair[1], 10, 64) 66 | 67 | if err == nil { 68 | pid = p 69 | break 70 | } 71 | } 72 | } 73 | 74 | if pid == 0 { 75 | return ErrProcNotFound 76 | } 77 | 78 | return exec.CommandContext(ctx, "kill", fmt.Sprintf("%d", pid)).Run() 79 | } 80 | 81 | func (b *LinuxBrowser) findBinaryPath() (string, error) { 82 | variants := []string{ 83 | "google-chrome-stable", 84 | "google-chrome-beta", 85 | "google-chrome-unstable", 86 | "chromium-browser", 87 | "chromium-browser-beta", 88 | "chromium-browser-unstable", 89 | } 90 | 91 | var result string 92 | 93 | // Find an installed one 94 | for _, name := range variants { 95 | _, err := exec.Command("which", name).Output() 96 | 97 | if err != nil { 98 | continue 99 | } 100 | 101 | result = name 102 | 103 | if result != "" { 104 | break 105 | } 106 | } 107 | 108 | if result == "" { 109 | return "", ErrBinNotFound 110 | } 111 | 112 | return result, nil 113 | } 114 | -------------------------------------------------------------------------------- /browser/browser_windows.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | type WindowsBrowser struct { 14 | opts Options 15 | } 16 | 17 | func New(opts Options) Browser { 18 | return &WindowsBrowser{opts} 19 | } 20 | 21 | func (b *WindowsBrowser) Open(ctx context.Context) (uint64, error) { 22 | path, err := b.findBinaryPath() 23 | 24 | if err != nil { 25 | return 0, err 26 | } 27 | 28 | args := []string{ 29 | "(", 30 | "Start-Process", 31 | "-FilePath", fmt.Sprintf("'%s'", path), 32 | "-ArgumentList", strings.Join(b.opts.ToFlags(), ","), 33 | "-PassThru", 34 | ").ID", 35 | } 36 | 37 | cmd := exec.Command("powershell", args...) 38 | 39 | out, err := cmd.Output() 40 | 41 | if err != nil { 42 | return 0, err 43 | } 44 | 45 | pid, err := strconv.ParseUint(strings.TrimSpace(string(out)), 10, 64) 46 | 47 | if err != nil { 48 | return 0, err 49 | } 50 | 51 | if b.opts.Detach { 52 | return pid, nil 53 | } 54 | 55 | <-ctx.Done() 56 | 57 | return 0, b.Close(context.Background(), pid) 58 | } 59 | 60 | func (b *WindowsBrowser) Close(ctx context.Context, pid uint64) error { 61 | if pid > 0 { 62 | if err := exec.Command("taskkill", "-pid", fmt.Sprintf("%d", pid)).Run(); err != nil { 63 | return ErrProcNotFound 64 | } 65 | } 66 | 67 | path, err := b.findBinaryPath() 68 | 69 | if err != nil { 70 | return err 71 | } 72 | 73 | opts := strings.Join(b.opts.ToFlags(), " ") 74 | psOut, err := exec.Command("WMIC", "path", "win32_process", "get", "Caption,Processid,Commandline").Output() 75 | 76 | if err != nil { 77 | return ErrProcNotFound 78 | } 79 | 80 | r := regexp.MustCompile(`([A-Za-z.]+)\s+([A-Za-z-=0-9.":\\\s]+)\s(\d+)`) 81 | targetCmd := fmt.Sprintf("%s %s", path, opts) 82 | 83 | outArr := strings.Split(strings.TrimSpace(string(psOut)), "\n") 84 | 85 | for _, str := range outArr { 86 | matches := r.FindAllStringSubmatch(str, -1) 87 | 88 | if len(matches) == 0 { 89 | continue 90 | } 91 | 92 | groups := matches[0] 93 | 94 | cmd := strings.TrimSpace(groups[2]) 95 | processID := strings.TrimSpace(groups[3]) 96 | 97 | if cmd == "" { 98 | continue 99 | } 100 | 101 | cmd = strings.ReplaceAll(cmd, `"`, "") 102 | 103 | if strings.HasSuffix(cmd, targetCmd) { 104 | p, err := strconv.ParseUint(processID, 10, 64) 105 | 106 | if err == nil { 107 | pid = p 108 | 109 | break 110 | } 111 | } 112 | } 113 | 114 | if pid == 0 { 115 | return ErrProcNotFound 116 | } 117 | 118 | return exec.CommandContext(ctx, "taskkill", "/PID", fmt.Sprintf("%d", pid)).Run() 119 | } 120 | 121 | func (b *WindowsBrowser) findBinaryPath() (string, error) { 122 | variants := []string{ 123 | "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", 124 | //"C:\\Program Files\\Chromimum\\Application\\chrome.exe", 125 | //"C:\\Users\\User\\AppData\\Local\\Google\\Chrome SxS\\Application\\chrome.exe", 126 | } 127 | 128 | var result string 129 | 130 | // Find an installed one 131 | for _, name := range variants { 132 | _, err := os.Stat(name) 133 | 134 | if err != nil { 135 | continue 136 | } 137 | 138 | result = name 139 | 140 | if result != "" { 141 | break 142 | } 143 | } 144 | 145 | if result == "" { 146 | return "", ErrBinNotFound 147 | } 148 | 149 | return result, nil 150 | } 151 | -------------------------------------------------------------------------------- /browser/errors.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrBinNotFound = errors.New("no compatible browser was found") 7 | ErrProcNotFound = errors.New("process not found") 8 | ) 9 | -------------------------------------------------------------------------------- /browser/flags.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | var headlessFlags = []string{ 4 | "--disable-background-networking", 5 | "--disable-background-timer-throttling", 6 | "--disable-breakpad", 7 | "--disable-canvas-aa", 8 | "--disable-client-side-phishing-detection", 9 | "--disable-cloud-import", 10 | "--disable-composited-antialiasing", 11 | "--disable-default-apps", 12 | "--disable-demo-mode", 13 | "--disable-dev-shm-usage", 14 | "--disable-extensions", 15 | "--disable-hang-monitor", 16 | "--disable-gesture-typing", 17 | "--disable-gpu", 18 | "--disable-gpu-sandbox", 19 | "--disable-infobars", 20 | "--disable-kill-after-bad-ipc", 21 | "--disable-notifications", 22 | "--disable-offer-store-unmasked-wallet-cards", 23 | "--disable-offer-upload-credit-cards", 24 | "--disable-office-editing-component-extension", 25 | "--disable-password-generation", 26 | "--disable-print-preview", 27 | "--disable-prompt-on-repost", 28 | "--disable-renderer-backgrounding", 29 | "--disable-seccomp-filter-sandbox", 30 | "--disable-setuid-sandbox", 31 | "--disable-smooth-scrolling", 32 | "--disable-speech-api", 33 | "--disable-sync", 34 | "--disable-tab-for-desktop-share", 35 | "--disable-translate", 36 | "--disable-voice-input", 37 | "--disable-wake-on-wifi", 38 | "--disable-web-security", 39 | "--disk-cache-dir=/tmp/cache-dir", 40 | "--disk-cache-size=10000000", 41 | "--enable-async-dns", 42 | "--enable-simple-cache-backend", 43 | "--enable-tcp-fast-open", 44 | "--enable-webgl", 45 | "--font-render-hinting=none", 46 | "--headless", 47 | "--hide-scrollbars", 48 | "--ignore-certificate-errors", 49 | "--ignore-certificate-errors-spki-list", 50 | "--ignore-gpu-blocklist", 51 | "--ignore-ssl-errors", 52 | "--log-level=0", 53 | "--media-cache-size=10000000", 54 | "--metrics-recording-only", 55 | "--mute-audio", 56 | "--no-default-browser-check", 57 | "--no-experiments", 58 | "--no-first-run", 59 | "--no-pings", 60 | "--no-sandbox", 61 | "--no-zygote", 62 | "--prerender-from-omnibox=disabled", 63 | "--safebrowsing-disable-auto-update", 64 | "--use-gl=swiftshader", 65 | } 66 | -------------------------------------------------------------------------------- /browser/options.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import "fmt" 4 | 5 | type Options struct { 6 | Detach bool 7 | Headless bool 8 | Address string 9 | Port uint64 10 | UserDir string 11 | } 12 | 13 | func NewDefaultOptions() Options { 14 | return Options{ 15 | Headless: false, 16 | Address: "", 17 | Port: 9222, 18 | UserDir: "", 19 | } 20 | } 21 | 22 | func (opts Options) ToURL() string { 23 | url := opts.Address 24 | 25 | if url == "" { 26 | url = "http://127.0.0.1" 27 | } 28 | 29 | return fmt.Sprintf("%s:%d", url, opts.Port) 30 | } 31 | 32 | func (opts Options) ToFlags() []string { 33 | flags := make([]string, 0, len(headlessFlags)+5) 34 | 35 | if opts.Headless { 36 | flags = append(flags, headlessFlags...) 37 | } 38 | 39 | if opts.UserDir != "" { 40 | flags = append(flags, fmt.Sprintf("--user-data-dir=%s", opts.UserDir)) 41 | } 42 | 43 | if opts.Address != "" { 44 | flags = append(flags, fmt.Sprintf("--remote-debugging-address=%s", opts.Address)) 45 | } 46 | 47 | flags = append(flags, fmt.Sprintf("--remote-debugging-port=%d", opts.Port)) 48 | 49 | return flags 50 | } 51 | -------------------------------------------------------------------------------- /cmd/browser.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/pkg/errors" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/MontFerret/cli/browser" 11 | "github.com/MontFerret/cli/config" 12 | ) 13 | 14 | func BrowserCommand(store *config.Store) *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "browser", 17 | Short: "Manage Ferret browsers", 18 | Long: "", 19 | Args: cobra.MaximumNArgs(0), 20 | PersistentPreRun: func(cmd *cobra.Command, _ []string) { 21 | store.BindFlags(cmd) 22 | }, 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | if len(args) == 0 { 25 | return cmd.Help() 26 | } 27 | 28 | return fmt.Errorf("unknown command %q", args[0]) 29 | }, 30 | } 31 | 32 | openCmd := &cobra.Command{ 33 | Use: "open", 34 | Short: "Open browser", 35 | Args: cobra.MaximumNArgs(0), 36 | PersistentPreRun: func(cmd *cobra.Command, _ []string) { 37 | store.BindFlags(cmd) 38 | }, 39 | RunE: func(cmd *cobra.Command, _ []string) error { 40 | pid, err := browser.Open(cmd.Context(), store.GetBrowserOptions()) 41 | 42 | if err != nil { 43 | return err 44 | } 45 | 46 | fmt.Println(pid) 47 | 48 | return nil 49 | }, 50 | } 51 | 52 | openCmd.Flags().BoolP(config.BrowserDetach, "d", false, "Start browser in background and print process ID") 53 | openCmd.Flags().Bool(config.BrowserHeadless, false, "Start browser in headless mode") 54 | openCmd.Flags().Uint64P(config.BrowserPort, "p", 9222, "Browser remote debugging port") 55 | openCmd.Flags().String(config.BrowserUserDir, "", "Browser user directory") 56 | 57 | closeCmd := &cobra.Command{ 58 | Use: "close", 59 | Short: "Close browser", 60 | Args: cobra.MaximumNArgs(1), 61 | PersistentPreRun: func(cmd *cobra.Command, _ []string) { 62 | store.BindFlags(cmd) 63 | }, 64 | RunE: func(cmd *cobra.Command, args []string) error { 65 | var pid uint64 66 | 67 | if len(args) > 0 { 68 | p, err := strconv.ParseUint(args[0], 10, 64) 69 | 70 | if err != nil { 71 | return errors.Wrap(err, "invalid pid number") 72 | } 73 | 74 | pid = p 75 | } 76 | 77 | return browser.Close(cmd.Context(), store.GetBrowserOptions(), pid) 78 | }, 79 | } 80 | 81 | cmd.AddCommand(openCmd) 82 | cmd.AddCommand(closeCmd) 83 | 84 | return cmd 85 | } 86 | -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/MontFerret/cli/config" 9 | ) 10 | 11 | // ConfigCommand command to manipulate with config file 12 | func ConfigCommand(store *config.Store) *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "config", 15 | Short: "Manage Ferret configs", 16 | Args: cobra.MaximumNArgs(0), 17 | PersistentPreRun: func(cmd *cobra.Command, _ []string) { 18 | store.BindFlags(cmd) 19 | }, 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | if len(args) == 0 { 22 | return cmd.Help() 23 | } 24 | 25 | return fmt.Errorf("unknown command %q", args[0]) 26 | }, 27 | } 28 | 29 | cmd.AddCommand(&cobra.Command{ 30 | Use: "get", 31 | Short: "Get a Ferret config value by key", 32 | Args: cobra.MinimumNArgs(1), 33 | PreRun: func(cmd *cobra.Command, _ []string) { 34 | store.BindFlags(cmd) 35 | }, 36 | RunE: func(_ *cobra.Command, args []string) error { 37 | val, err := store.Get(args[0]) 38 | 39 | if err == nil { 40 | fmt.Println(val) 41 | 42 | return nil 43 | } 44 | 45 | if err == config.ErrInvalidFlag { 46 | return fmt.Errorf("%s\n%s", err, config.FlagsStr) 47 | } 48 | 49 | return err 50 | }, 51 | }) 52 | 53 | cmd.AddCommand(&cobra.Command{ 54 | Use: "set", 55 | Short: "Set a Ferret config value by key", 56 | Args: cobra.MinimumNArgs(2), 57 | PreRun: func(cmd *cobra.Command, _ []string) { 58 | store.BindFlags(cmd) 59 | }, 60 | RunE: func(_ *cobra.Command, args []string) error { 61 | err := store.Set(args[0], args[1]) 62 | 63 | if err == config.ErrInvalidFlag { 64 | return fmt.Errorf("%s\n%s", err, config.FlagsStr) 65 | } 66 | 67 | return err 68 | }, 69 | }) 70 | 71 | cmd.AddCommand(&cobra.Command{ 72 | Use: "list", 73 | Aliases: []string{"ls"}, 74 | Short: "Get a list of Ferret config values", 75 | Args: cobra.MaximumNArgs(0), 76 | PreRun: func(cmd *cobra.Command, _ []string) { 77 | store.BindFlags(cmd) 78 | }, 79 | Run: func(_ *cobra.Command, _ []string) { 80 | for _, kv := range store.List() { 81 | fmt.Printf("%s: %v\n", kv.Key, kv.Value) 82 | } 83 | }, 84 | }) 85 | 86 | return cmd 87 | } 88 | -------------------------------------------------------------------------------- /cmd/exec.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/url" 9 | "os" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/pkg/errors" 14 | "github.com/spf13/cobra" 15 | 16 | "github.com/MontFerret/ferret/pkg/runtime/core" 17 | 18 | "github.com/MontFerret/cli/browser" 19 | "github.com/MontFerret/cli/config" 20 | "github.com/MontFerret/cli/repl" 21 | "github.com/MontFerret/cli/runtime" 22 | ) 23 | 24 | const ( 25 | ExecParamFlag = "param" 26 | ) 27 | 28 | // RumCommand command to execute FQL scripts 29 | func ExecCommand(store *config.Store) *cobra.Command { 30 | cmd := &cobra.Command{ 31 | Use: "exec", 32 | Short: "Execute a FQL script or launch REPL", 33 | Args: cobra.MinimumNArgs(0), 34 | PreRun: func(cmd *cobra.Command, _ []string) { 35 | store.BindFlags(cmd) 36 | }, 37 | RunE: func(cmd *cobra.Command, args []string) error { 38 | paramFlag, err := cmd.Flags().GetStringArray(ExecParamFlag) 39 | 40 | if err != nil { 41 | return err 42 | } 43 | 44 | params, err := parseExecParams(paramFlag) 45 | 46 | if err != nil { 47 | return err 48 | } 49 | 50 | store := config.From(cmd.Context()) 51 | 52 | rtOpts := store.GetRuntimeOptions() 53 | 54 | if rtOpts.WithBrowser { 55 | brOpts := store.GetBrowserOptions() 56 | brOpts.Detach = true 57 | brOpts.Headless = rtOpts.WithHeadlessBrowser 58 | 59 | if rtOpts.BrowserAddress != "" { 60 | u, err := url.Parse(rtOpts.BrowserAddress) 61 | 62 | if err != nil { 63 | return errors.Wrap(err, "invalid browser address") 64 | } 65 | 66 | if u.Port() != "" { 67 | p, err := strconv.ParseUint(u.Port(), 10, 64) 68 | 69 | if err != nil { 70 | return err 71 | } 72 | 73 | brOpts.Port = p 74 | } 75 | } 76 | 77 | pid, err := browser.Open(cmd.Context(), brOpts) 78 | 79 | if err != nil { 80 | return err 81 | } 82 | 83 | defer browser.Close(cmd.Context(), brOpts, pid) 84 | } 85 | 86 | stat, _ := os.Stdin.Stat() 87 | 88 | if (stat.Mode() & os.ModeCharDevice) == 0 { 89 | // check whether the app is getting a query via standard input 90 | std := bufio.NewReader(os.Stdin) 91 | 92 | content, err := io.ReadAll(std) 93 | 94 | if err != nil { 95 | return err 96 | } 97 | 98 | return execScript(cmd, rtOpts, params, string(content)) 99 | } 100 | 101 | if len(args) == 0 { 102 | return startRepl(cmd, rtOpts, params) 103 | } 104 | 105 | content, err := os.ReadFile(args[0]) 106 | 107 | if err != nil { 108 | return err 109 | } 110 | 111 | return execScript(cmd, rtOpts, params, string(content)) 112 | }, 113 | } 114 | 115 | cmd.Flags().StringArrayP(ExecParamFlag, "p", []string{}, "Query bind parameter (--param=foo:\"bar\", --param=id:1)") 116 | cmd.Flags().StringP(config.ExecRuntime, "r", runtime.DefaultRuntime, "Ferret runtime type (\"builtin\"|$url)") 117 | cmd.Flags().String(config.ExecProxy, "x", "Proxy server address") 118 | cmd.Flags().String(config.ExecUserAgent, "a", "User agent header") 119 | cmd.Flags().StringP(config.ExecBrowserAddress, "d", runtime.DefaultBrowser, "Browser debugger address") 120 | cmd.Flags().BoolP(config.ExecWithBrowser, "B", false, "Open browser for script execution") 121 | cmd.Flags().BoolP(config.ExecWithBrowserHeadless, "b", false, "Open browser for script execution in headless mode") 122 | cmd.Flags().BoolP(config.ExecKeepCookies, "c", false, "Keep cookies between queries") 123 | 124 | return cmd 125 | } 126 | 127 | func startRepl(cmd *cobra.Command, opts runtime.Options, params map[string]interface{}) error { 128 | return repl.Start(cmd.Context(), opts, params) 129 | } 130 | 131 | func execScript(cmd *cobra.Command, opts runtime.Options, params map[string]interface{}, query string) error { 132 | out, err := runtime.Run(cmd.Context(), opts, query, params) 133 | 134 | if err != nil { 135 | return err 136 | } 137 | 138 | fmt.Println(string(out)) 139 | 140 | return err 141 | } 142 | 143 | func parseExecParams(flags []string) (map[string]interface{}, error) { 144 | res := make(map[string]interface{}) 145 | 146 | for _, entry := range flags { 147 | pair := strings.SplitN(entry, ":", 2) 148 | 149 | if len(pair) < 2 { 150 | return nil, core.Error(core.ErrInvalidArgument, entry) 151 | } 152 | 153 | var value interface{} 154 | key := pair[0] 155 | 156 | err := json.Unmarshal([]byte(pair[1]), &value) 157 | 158 | if err != nil { 159 | fmt.Println(pair[1]) 160 | return nil, err 161 | } 162 | 163 | res[key] = value 164 | } 165 | 166 | return res, nil 167 | } 168 | -------------------------------------------------------------------------------- /cmd/update.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/MontFerret/cli/config" 8 | "github.com/MontFerret/cli/internal/selfupdate" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func SelfUpdateCommand(store *config.Store) *cobra.Command { 14 | cmd := cobra.Command{ 15 | Use: "update", 16 | Args: cobra.MaximumNArgs(0), 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | if len(args) == 0 { 19 | return cmd.Help() 20 | } 21 | 22 | return fmt.Errorf("unknown command %q", args[0]) 23 | }, 24 | } 25 | cmd.AddCommand(&cobra.Command{ 26 | Use: "self", 27 | Short: "Update Ferret CLI", 28 | Args: cobra.MaximumNArgs(0), 29 | RunE: func(_ *cobra.Command, _ []string) error { 30 | updater, err := selfupdate.NewUpdater( 31 | store.RepoOwner(), 32 | store.Repo(), 33 | runtime.GOOS, 34 | runtime.GOARCH, 35 | store.AppVersion(), 36 | ) 37 | if err != nil { 38 | return err 39 | } 40 | return updater.Update() 41 | }, 42 | }) 43 | 44 | return &cmd 45 | } 46 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/MontFerret/cli/config" 9 | "github.com/MontFerret/cli/runtime" 10 | ) 11 | 12 | // VersionCommand command to display version 13 | func VersionCommand(store *config.Store) *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "version", 16 | Short: "Show the CLI version information", 17 | Args: cobra.MaximumNArgs(0), 18 | PreRun: func(cmd *cobra.Command, _ []string) { 19 | store.BindFlags(cmd) 20 | }, 21 | RunE: func(cmd *cobra.Command, _ []string) error { 22 | return runVersion(cmd, store) 23 | }, 24 | } 25 | 26 | cmd.Flags().StringP(config.ExecRuntime, "r", runtime.DefaultRuntime, "Ferret runtime type (\"builtin\"|$url)") 27 | 28 | return cmd 29 | } 30 | 31 | func runVersion(cmd *cobra.Command, store *config.Store) error { 32 | rt, err := runtime.New(store.GetRuntimeOptions()) 33 | 34 | if err != nil { 35 | return err 36 | } 37 | 38 | ver, err := rt.Version(cmd.Context()) 39 | 40 | if err != nil { 41 | return err 42 | } 43 | 44 | fmt.Println("Version:") 45 | fmt.Printf(" Self: %s\n", store.AppVersion()) 46 | fmt.Printf(" Runtime: %s\n", ver) 47 | 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /config/context.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "context" 4 | 5 | type ctxKeyT struct{} 6 | 7 | var ctxKey = ctxKeyT{} 8 | 9 | func With(ctx context.Context, store *Store) context.Context { 10 | return context.WithValue(ctx, ctxKey, store) 11 | } 12 | 13 | func From(ctx context.Context) *Store { 14 | val := ctx.Value(ctxKey) 15 | 16 | store, ok := val.(*Store) 17 | 18 | if ok { 19 | return store 20 | } 21 | 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /config/errors.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrInvalidFlag = errors.New("invalid flag") 7 | ) 8 | -------------------------------------------------------------------------------- /config/flags.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "strings" 4 | 5 | const ( 6 | LoggerLevel = "log-level" 7 | 8 | ExecRuntime = "runtime" 9 | ExecKeepCookies = "browser-cookies" 10 | ExecWithBrowser = "browser-open" 11 | ExecBrowserAddress = "browser-address" 12 | ExecWithBrowserHeadless = "browser-headless" 13 | ExecProxy = "proxy" 14 | ExecUserAgent = "user-agent" 15 | 16 | BrowserPort = "port" 17 | BrowserDetach = "detach" 18 | BrowserHeadless = "headless" 19 | BrowserUserDir = "user-dir" 20 | ) 21 | 22 | var Flags = []string{ 23 | LoggerLevel, 24 | ExecRuntime, 25 | ExecKeepCookies, 26 | ExecBrowserAddress, 27 | ExecWithBrowser, 28 | ExecWithBrowserHeadless, 29 | ExecProxy, 30 | ExecUserAgent, 31 | } 32 | var FlagsStr = strings.Join(Flags, `"|"`) 33 | 34 | func isSupportedFlag(name string) bool { 35 | for _, f := range Flags { 36 | if f == name { 37 | return true 38 | } 39 | } 40 | 41 | return false 42 | } 43 | -------------------------------------------------------------------------------- /config/init.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "strings" 8 | 9 | "github.com/mitchellh/go-homedir" 10 | "github.com/pkg/errors" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/pflag" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | func ensureConfigFile(v *viper.Viper, appName string) error { 17 | home, err := homedir.Dir() 18 | 19 | if err != nil { 20 | return err 21 | } 22 | 23 | projectDir := path.Join(home, "."+appName) 24 | 25 | _, err = os.Stat(projectDir) 26 | 27 | // first launch 28 | if errors.Is(err, os.ErrNotExist) { 29 | if err := os.Mkdir(projectDir, 0755); err != nil { 30 | return errors.Wrap(err, "create project directory") 31 | } 32 | } 33 | 34 | configFile := path.Join(projectDir, "config.yaml") 35 | 36 | _, err = os.Stat(configFile) 37 | 38 | if errors.Is(err, os.ErrNotExist) { 39 | if _, err := os.Create(configFile); err != nil { 40 | return errors.Wrap(err, "create project config file") 41 | } 42 | } 43 | 44 | // Set the base name of the config file, without the file extension. 45 | v.SetConfigName("config") 46 | v.SetConfigType("yaml") 47 | v.AddConfigPath(projectDir) 48 | 49 | return nil 50 | } 51 | 52 | func bindFlags(v *viper.Viper, flags *pflag.FlagSet, envPrefix string) { 53 | flags.VisitAll(func(f *pflag.Flag) { 54 | v.BindPFlag(f.Name, f) 55 | 56 | // Environment variables can't have dashes in them, so bind them to their equivalent 57 | // keys with underscores, e.g. --favorite-color to STING_FAVORITE_COLOR 58 | if strings.Contains(f.Name, "-") { 59 | envVarSuffix := strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_")) 60 | v.BindEnv(f.Name, fmt.Sprintf("%s_%s", envPrefix, envVarSuffix)) 61 | } 62 | 63 | // Apply the viper config value to the flag when the flag is not set and viper has a value 64 | if !f.Changed && v.IsSet(f.Name) { 65 | val := v.Get(f.Name) 66 | flags.Set(f.Name, fmt.Sprintf("%v", val)) 67 | } 68 | }) 69 | } 70 | 71 | func bindFlagsFor(v *viper.Viper, cmd *cobra.Command, envPrefix string) { 72 | bindFlags(v, cmd.Flags(), envPrefix) 73 | bindFlags(v, cmd.PersistentFlags(), envPrefix) 74 | } 75 | -------------------------------------------------------------------------------- /config/store.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/viper" 8 | 9 | "github.com/MontFerret/cli/browser" 10 | "github.com/MontFerret/cli/logger" 11 | "github.com/MontFerret/cli/runtime" 12 | ) 13 | 14 | type ( 15 | KV struct { 16 | Key string 17 | Value interface{} 18 | } 19 | 20 | Store struct { 21 | appName string 22 | version string 23 | envPrefix string 24 | v *viper.Viper 25 | } 26 | ) 27 | 28 | func NewStore(appName, version string) (*Store, error) { 29 | v := viper.New() 30 | 31 | if err := ensureConfigFile(v, appName); err != nil { 32 | return nil, err 33 | } 34 | 35 | // Attempt to read the config file, gracefully ignoring errors 36 | // caused by a config file not being found. Return an error 37 | // if we cannot parse the config file. 38 | if err := v.ReadInConfig(); err != nil { 39 | // It's okay if there isn't a config file 40 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 41 | return nil, err 42 | } 43 | } 44 | 45 | envPrefix := strings.ToUpper(appName) 46 | 47 | // When we bind flags to environment variables expect that the 48 | // environment variables are prefixed, e.g. a flag like --number 49 | // binds to an environment variable STING_NUMBER. This helps 50 | // avoid conflicts. 51 | v.SetEnvPrefix(envPrefix) 52 | 53 | // Bind to environment variables 54 | // Works great for simple config names, but needs help for names 55 | // like --favorite-color which we fix in the bindFlags function 56 | v.AutomaticEnv() 57 | 58 | return &Store{appName, version, envPrefix, v}, nil 59 | } 60 | 61 | func (s *Store) AppName() string { 62 | return s.appName 63 | } 64 | 65 | func (s *Store) AppVersion() string { 66 | return s.version 67 | } 68 | 69 | func (s *Store) RepoOwner() string { 70 | return "MontFerret" 71 | } 72 | 73 | func (s *Store) Repo() string { 74 | return "cli" 75 | } 76 | 77 | // Bind the current command's flags to viper 78 | func (s *Store) BindFlags(cmd *cobra.Command) { 79 | bindFlagsFor(s.v, cmd, s.envPrefix) 80 | } 81 | 82 | func (s *Store) GetLoggerOptions() logger.Options { 83 | opts := logger.NewDefaultOptions() 84 | 85 | if s.v.IsSet(LoggerLevel) { 86 | opts.Level = logger.ToLevel(s.v.GetString(LoggerLevel)) 87 | } 88 | 89 | return opts 90 | } 91 | 92 | func (s *Store) GetRuntimeOptions() runtime.Options { 93 | opts := runtime.NewDefaultOptions() 94 | 95 | if s.v.IsSet(ExecRuntime) { 96 | opts.Type = s.v.GetString(ExecRuntime) 97 | } 98 | 99 | if s.v.IsSet(ExecBrowserAddress) { 100 | opts.BrowserAddress = s.v.GetString(ExecBrowserAddress) 101 | } 102 | 103 | if s.v.IsSet(ExecKeepCookies) { 104 | opts.KeepCookies = s.v.GetBool(ExecKeepCookies) 105 | } 106 | 107 | if s.v.IsSet(ExecWithBrowserHeadless) { 108 | opts.WithHeadlessBrowser = s.v.GetBool(ExecWithBrowserHeadless) 109 | } 110 | 111 | if s.v.IsSet(ExecWithBrowser) { 112 | opts.WithBrowser = s.v.GetBool(ExecWithBrowser) 113 | } else if opts.WithHeadlessBrowser { 114 | opts.WithBrowser = true 115 | } 116 | 117 | if s.v.IsSet(ExecProxy) { 118 | opts.Proxy = s.v.GetString(ExecProxy) 119 | } 120 | 121 | if s.v.IsSet(ExecUserAgent) { 122 | opts.UserAgent = s.v.GetString(ExecUserAgent) 123 | } 124 | 125 | return opts 126 | } 127 | 128 | func (s *Store) GetBrowserOptions() browser.Options { 129 | opts := browser.NewDefaultOptions() 130 | 131 | if s.v.IsSet(BrowserDetach) { 132 | opts.Detach = s.v.GetBool(BrowserDetach) 133 | } 134 | 135 | if s.v.IsSet(BrowserHeadless) { 136 | opts.Headless = s.v.GetBool(BrowserHeadless) 137 | } 138 | 139 | if s.v.IsSet(BrowserPort) { 140 | opts.Port = s.v.GetUint64(BrowserPort) 141 | } 142 | 143 | if s.v.IsSet(BrowserUserDir) { 144 | opts.UserDir = s.v.GetString(BrowserUserDir) 145 | } 146 | 147 | return opts 148 | } 149 | 150 | func (s *Store) Get(key string) (interface{}, error) { 151 | if !isSupportedFlag(key) { 152 | return nil, ErrInvalidFlag 153 | } 154 | 155 | return s.v.Get(key), nil 156 | } 157 | 158 | func (s *Store) Set(key, val string) error { 159 | if !isSupportedFlag(key) { 160 | return ErrInvalidFlag 161 | } 162 | 163 | s.v.Set(key, val) 164 | 165 | return s.v.WriteConfig() 166 | } 167 | 168 | func (s *Store) List() []KV { 169 | list := make([]KV, 0, len(Flags)) 170 | 171 | for _, key := range Flags { 172 | list = append(list, KV{ 173 | Key: key, 174 | Value: s.v.Get(key), 175 | }) 176 | } 177 | 178 | return list 179 | } 180 | -------------------------------------------------------------------------------- /ferret/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/rs/zerolog" 11 | "github.com/spf13/cobra" 12 | 13 | "github.com/MontFerret/cli/cmd" 14 | "github.com/MontFerret/cli/config" 15 | "github.com/MontFerret/cli/logger" 16 | ) 17 | 18 | const ( 19 | name = "ferret" 20 | ) 21 | 22 | var version string 23 | 24 | func main() { 25 | store, err := config.NewStore(name, version) 26 | if err != nil { 27 | exit(err) 28 | } 29 | 30 | rootCmd := &cobra.Command{ 31 | Use: name, 32 | SilenceErrors: true, 33 | SilenceUsage: true, 34 | TraverseChildren: true, 35 | PersistentPreRun: func(cmd *cobra.Command, _ []string) { 36 | store.BindFlags(cmd) 37 | }, 38 | RunE: func(cmd *cobra.Command, args []string) error { 39 | if len(args) == 0 { 40 | return cmd.Help() 41 | } 42 | 43 | return fmt.Errorf("unknown command %q", args[0]) 44 | }, 45 | } 46 | 47 | rootCmd.CompletionOptions.DisableDefaultCmd = true 48 | rootCmd.PersistentFlags().StringP(config.LoggerLevel, "l", zerolog.InfoLevel.String(), fmt.Sprintf("Set the logging level (%s)", logger.LevelsFmt())) 49 | 50 | rootCmd.AddCommand( 51 | cmd.VersionCommand(store), 52 | cmd.ConfigCommand(store), 53 | cmd.ExecCommand(store), 54 | cmd.BrowserCommand(store), 55 | cmd.SelfUpdateCommand(store), 56 | ) 57 | 58 | c := make(chan os.Signal, 1) 59 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 60 | 61 | ctx, cancel := context.WithCancel(context.Background()) 62 | 63 | go func() { 64 | for { 65 | <-c 66 | cancel() 67 | } 68 | }() 69 | 70 | if err := rootCmd.ExecuteContext(config.With(ctx, store)); err != nil { 71 | exit(err) 72 | } 73 | } 74 | 75 | func exit(err error) { 76 | if err != nil { 77 | fmt.Println(err) 78 | os.Exit(1) 79 | } 80 | 81 | os.Exit(0) 82 | } 83 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/MontFerret/cli 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/Masterminds/semver/v3 v3.3.1 9 | github.com/MontFerret/ferret v0.18.1 10 | github.com/chzyer/readline v1.5.1 11 | github.com/go-waitfor/waitfor v1.0.0 12 | github.com/go-waitfor/waitfor-http v1.0.0 13 | github.com/mitchellh/go-homedir v1.1.0 14 | github.com/natefinch/lumberjack v2.0.0+incompatible 15 | github.com/pkg/errors v0.9.1 16 | github.com/rs/zerolog v1.34.0 17 | github.com/spf13/cobra v1.9.1 18 | github.com/spf13/pflag v1.0.6 19 | github.com/spf13/viper v1.20.1 20 | ) 21 | 22 | require ( 23 | github.com/BurntSushi/toml v1.5.0 // indirect 24 | github.com/PuerkitoBio/goquery v1.10.3 // indirect 25 | github.com/andybalholm/cascadia v1.3.3 // indirect 26 | github.com/antchfx/htmlquery v1.3.4 // indirect 27 | github.com/antchfx/xpath v1.3.4 // indirect 28 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect 29 | github.com/cenkalti/backoff v2.2.1+incompatible // indirect 30 | github.com/corpix/uarand v0.2.0 // indirect 31 | github.com/fsnotify/fsnotify v1.8.0 // indirect 32 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 33 | github.com/gobwas/glob v0.2.3 // indirect 34 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 35 | github.com/gorilla/css v1.0.1 // indirect 36 | github.com/gorilla/websocket v1.5.3 // indirect 37 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 38 | github.com/mafredri/cdp v0.35.0 // indirect 39 | github.com/mattn/go-colorable v0.1.13 // indirect 40 | github.com/mattn/go-isatty v0.0.19 // indirect 41 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 42 | github.com/sagikazarmark/locafero v0.7.0 // indirect 43 | github.com/sethgrid/pester v1.2.0 // indirect 44 | github.com/sourcegraph/conc v0.3.0 // indirect 45 | github.com/spf13/afero v1.12.0 // indirect 46 | github.com/spf13/cast v1.7.1 // indirect 47 | github.com/subosito/gotenv v1.6.0 // indirect 48 | github.com/wI2L/jettison v0.7.4 // indirect 49 | go.uber.org/atomic v1.9.0 // indirect 50 | go.uber.org/multierr v1.9.0 // indirect 51 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect 52 | golang.org/x/net v0.40.0 // indirect 53 | golang.org/x/sync v0.14.0 // indirect 54 | golang.org/x/sys v0.33.0 // indirect 55 | golang.org/x/text v0.25.0 // indirect 56 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 57 | gopkg.in/yaml.v3 v3.0.1 // indirect 58 | ) 59 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 2 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 4 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 5 | github.com/MontFerret/ferret v0.18.1 h1:HtOPA1HR07gmqLIbtskG76pegUFUDknxajs+0gHpNg4= 6 | github.com/MontFerret/ferret v0.18.1/go.mod h1:GtMDXmUKSj9Vg6cj9Ss/GeoBrntegKz0Heo4wfVZQ+A= 7 | github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= 8 | github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= 9 | github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= 10 | github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= 11 | github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ= 12 | github.com/antchfx/htmlquery v1.3.4/go.mod h1:K9os0BwIEmLAvTqaNSua8tXLWRWZpocZIH73OzWQbwM= 13 | github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= 14 | github.com/antchfx/xpath v1.3.4 h1:1ixrW1VnXd4HurCj7qnqnR0jo14g8JMe20Fshg1Vgz4= 15 | github.com/antchfx/xpath v1.3.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= 16 | github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= 17 | github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= 18 | github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= 19 | github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 20 | github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= 21 | github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= 22 | github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= 23 | github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= 24 | github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= 25 | github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= 26 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 27 | github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= 28 | github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 29 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 30 | github.com/corpix/uarand v0.2.0 h1:U98xXwud/AVuCpkpgfPF7J5TQgr7R5tqT8VZP5KWbzE= 31 | github.com/corpix/uarand v0.2.0/go.mod h1:/3Z1QIqWkDIhf6XWn/08/uMHoQ8JUoTIKc2iPchBOmM= 32 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 33 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 34 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 35 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 36 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 37 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 38 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 39 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 40 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 41 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 42 | github.com/go-waitfor/waitfor v1.0.0 h1:KX6SpTtEM2OOwJu5QP6MXvdi0Llq55d8dywSSZD+gSI= 43 | github.com/go-waitfor/waitfor v1.0.0/go.mod h1:a5e6B1hss5InR3moU7xAOP2thPsbjbTArD5+Kud4YaQ= 44 | github.com/go-waitfor/waitfor-http v1.0.0 h1:1PJwsR8vpWABhmS8uHV54i47Iy3YuJGyBOrV7giaE+M= 45 | github.com/go-waitfor/waitfor-http v1.0.0/go.mod h1:iI9AiSuTDkxbPyNhuOcHG/3Ob08CIcDdtcbbsG2RfCA= 46 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 47 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 48 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 49 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 50 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 51 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 52 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 53 | github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= 54 | github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= 55 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 56 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 57 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 58 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 59 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 60 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 61 | github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k= 62 | github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= 63 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 64 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 65 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 66 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 67 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 68 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 69 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 70 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 71 | github.com/mafredri/cdp v0.35.0 h1:fKQ6LbcH3WsxVrWbi/DSgLunJTqmF5o/7w8iFDDj71c= 72 | github.com/mafredri/cdp v0.35.0/go.mod h1:xS8dVzwKfYswsOHG05SfDCbhNrO89kWVJyMj5vD+zYo= 73 | github.com/mafredri/go-lint v0.0.0-20180911205320-920981dfc79e/go.mod h1:k/zdyxI3q6dup24o8xpYjJKTCf2F7rfxLp6w/efTiWs= 74 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 75 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 76 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 77 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 78 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 79 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 80 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 81 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 82 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 83 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 84 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 85 | github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM= 86 | github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= 87 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 88 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 89 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 90 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 91 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 92 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 93 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 94 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 95 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 96 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 97 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 98 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 99 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 100 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 101 | github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= 102 | github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= 103 | github.com/segmentio/encoding v0.3.4 h1:WM4IBnxH8B9TakiM2QD5LyNl9JSndh88QbHqVC+Pauc= 104 | github.com/segmentio/encoding v0.3.4/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM= 105 | github.com/sethgrid/pester v1.2.0 h1:adC9RS29rRUef3rIKWPOuP1Jm3/MmB6ke+OhE5giENI= 106 | github.com/sethgrid/pester v1.2.0/go.mod h1:hEUINb4RqvDxtoCaU0BNT/HV4ig5kfgOasrf1xcvr0A= 107 | github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= 108 | github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= 109 | github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= 110 | github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= 111 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 112 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 113 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 114 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 115 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 116 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 117 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 118 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 119 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 120 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 121 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 122 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 123 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 124 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 125 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 126 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 127 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 128 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 129 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 130 | github.com/wI2L/jettison v0.7.4 h1:ptjriu75R/k5RAZO0DJzy2t55f7g+dPiBxBY38icaKg= 131 | github.com/wI2L/jettison v0.7.4/go.mod h1:O+F+T7X7ZN6kTsd167Qk4aZMC8jNrH48SMedNmkfPb0= 132 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 133 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 134 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 135 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 136 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 137 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 138 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 139 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 140 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 141 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 142 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 143 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 144 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 145 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 146 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 147 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 148 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 149 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 150 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 151 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 152 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 153 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 154 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 155 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 156 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 157 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 158 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 159 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 160 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 161 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 162 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 163 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 164 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 165 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 166 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 167 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 168 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 169 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 170 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 171 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 172 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 173 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 174 | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 175 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 176 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 177 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 178 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 179 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 180 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 181 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 182 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 183 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 184 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 185 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 186 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 187 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 188 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 189 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 190 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 191 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 192 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 193 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 194 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 195 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 196 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 197 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 198 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 199 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 200 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 201 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 202 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 203 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 204 | golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 205 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 206 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 207 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 208 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 209 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 210 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 211 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 212 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 213 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 214 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 215 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 216 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 217 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 218 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 219 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 220 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 221 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 222 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 223 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 224 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright MontFerret Team 2023 3 | # Licensed under the MIT license. 4 | 5 | set -e 6 | 7 | # Declare constants 8 | readonly projectName="MontFerret" 9 | readonly appName="cli" 10 | readonly binName="ferret" 11 | readonly fullAppName="Ferret CLI" 12 | readonly baseUrl="https://github.com/${projectName}/${appName}/releases/download" 13 | 14 | # Declare default values 15 | readonly defaultLocation="${HOME}/.ferret" 16 | readonly defaultVersion="latest" 17 | 18 | # Print a message to stdout 19 | report() { 20 | command printf "%s\n" "$*" 2>/dev/null 21 | } 22 | 23 | # Check if a command is available 24 | command_exists() { 25 | command -v "$1" >/dev/null 2>&1 26 | } 27 | 28 | # Check if a path exists 29 | check_path() { 30 | if [ -z "${1-}" ] || [ ! -f "${1}" ]; then 31 | return 1 32 | fi 33 | 34 | report "${1}" 35 | } 36 | 37 | # Validate user input 38 | validate_input() { 39 | local location="$1" 40 | local version="$2" 41 | 42 | if [ -z "$location" ]; then 43 | report "Invalid location: $location" 44 | exit 1 45 | fi 46 | 47 | # Check if location exists 48 | if [ ! -d "$location" ]; then 49 | report "Location does not exist: $location" 50 | exit 1 51 | fi 52 | 53 | # Check if location is writable 54 | if [ ! -w "$location" ]; then 55 | report "Location is not writable: $location" 56 | exit 1 57 | fi 58 | 59 | if [ "$version" != "latest" ]; then 60 | # Remove leading 'v' if present 61 | version="${version#v}" 62 | 63 | # Check if version is valid using grep 64 | if ! echo "$version" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+$"; then 65 | report "Invalid version: $version" 66 | exit 1 67 | fi 68 | fi 69 | } 70 | 71 | # Detect the profile file 72 | detect_profile() { 73 | local profile="" 74 | local detected_profile="" 75 | 76 | if [ "${PROFILE-}" = '/dev/null' ]; then 77 | # the user has specifically requested NOT to have us touch their profile 78 | return 79 | fi 80 | 81 | if [ -n "${PROFILE}" ] && [ -f "${PROFILE}" ]; then 82 | report "${PROFILE}" 83 | return 84 | fi 85 | 86 | if command_exists bash; then 87 | if [ -f "$HOME/.bashrc" ]; then 88 | detected_profile="$HOME/.bashrc" 89 | elif [ -f "$HOME/.bash_profile" ]; then 90 | detected_profile="$HOME/.bash_profile" 91 | fi 92 | elif command_exists zsh; then 93 | if [ -f "$HOME/.zshrc" ]; then 94 | detected_profile="$HOME/.zshrc" 95 | fi 96 | fi 97 | 98 | if [ -z "$detected_profile" ]; then 99 | for profile_name in ".zshrc" ".bashrc" ".bash_profile" ".profile"; do 100 | if detected_profile="$(check_path "${HOME}/${profile_name}")"; then 101 | break 102 | fi 103 | done 104 | fi 105 | 106 | if [ -n "$detected_profile" ]; then 107 | report "$detected_profile" 108 | fi 109 | } 110 | 111 | # Update the profile file 112 | update_profile() { 113 | local location="$1" 114 | local profile="$(detect_profile)" 115 | 116 | if [ -z "$profile" ]; then 117 | report "No profile found. Skipping PATH update." 118 | return 119 | fi 120 | 121 | report "Checking if $location is already in PATH" 122 | 123 | if echo ":$PATH:" | grep -q ":$location:"; then 124 | report "$location is already in PATH" 125 | return 126 | fi 127 | 128 | report "Updating profile $profile" 129 | 130 | if [ -z "$profile" ]; then 131 | report "Profile not found. Tried ${DETECTED_PROFILE-} (as defined in \$PROFILE), ~/.bashrc, ~/.bash_profile, ~/.zshrc, and ~/.profile." 132 | report "Append the following lines to the correct file yourself:" 133 | report 134 | report "export PATH=\$PATH:${location}" 135 | report 136 | else 137 | if ! grep -q "${location}" "$profile"; then 138 | report "export PATH=\$PATH:${location}" >>"$profile" 139 | fi 140 | fi 141 | } 142 | 143 | # Get the platform-specific filename suffix 144 | get_platform_suffix() { 145 | local platform_name="$(uname)" 146 | local arch_name="$(uname -m)" 147 | local platform="" 148 | local arch="" 149 | 150 | case "$platform_name" in 151 | "Darwin") 152 | platform="_darwin" 153 | ;; 154 | "Linux") 155 | platform="_linux" 156 | ;; 157 | "Windows") 158 | platform="_windows" 159 | ;; 160 | *) 161 | report "$platform_name is not supported. Exiting..." 162 | exit 1 163 | ;; 164 | esac 165 | 166 | case "$arch_name" in 167 | "x86_64") 168 | arch="_x86_64" 169 | ;; 170 | "aarch64" | "arm64") 171 | arch="_arm64" 172 | ;; 173 | *) 174 | report "$arch_name is not supported. Exiting..." 175 | exit 1 176 | ;; 177 | esac 178 | 179 | echo "${platform}${arch}" 180 | } 181 | 182 | get_version_tag() { 183 | local version="$1" 184 | 185 | if [ "$version" = "latest" ]; then 186 | local url="https://api.github.com/repos/${projectName}/${appName}/releases/latest" 187 | 188 | curl -sSL "${url}" | grep "tag_name" | cut -d '"' -f 4 189 | else 190 | # Check if the version starts with a 'v' 191 | if [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 192 | echo "$version" 193 | else 194 | echo "v$version" 195 | fi 196 | fi 197 | } 198 | 199 | # Install the package 200 | install() { 201 | local location="${LOCATION:-$defaultLocation}" 202 | local version=$(get_version_tag "${VERSION:-$defaultVersion}") 203 | local tmp_dir="$(mktemp -d -t "${projectName}.${appName}.XXXXXXX")" 204 | 205 | validate_input "$location" "$version" 206 | 207 | report "Installing ${projectName} ${appName} ${version}..." 208 | 209 | # Download the archive to a temporary location 210 | local suffix="$(get_platform_suffix)" 211 | local file_name="${appName}${suffix}" 212 | local download_dir="${tmp_dir}/${file_name}@${version}" 213 | 214 | mkdir -p "${download_dir}" 215 | 216 | local download_file="${download_dir}/${file_name}.tar.gz" 217 | local url="${baseUrl}/${version}/${file_name}.tar.gz" 218 | 219 | report "Downloading package $url as $download_file" 220 | 221 | curl -sSL "${url}" | tar xz --directory "${download_dir}" 222 | 223 | local downloaded_file="${download_dir}/${binName}" 224 | 225 | report "Copying ${downloaded_file} to ${location}" 226 | 227 | cp "${downloaded_file}" "${location}" 228 | 229 | local executable="${location}/${binName}" 230 | 231 | chmod +x "${executable}" 232 | 233 | update_profile "${location}" 234 | 235 | report "New version of ${fullAppName} installed to ${location}" 236 | 237 | "$executable" version 238 | } 239 | 240 | # Call the main function 241 | install 242 | -------------------------------------------------------------------------------- /internal/selfupdate/github.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | const ( 13 | baseURL = "https://api.github.com" 14 | ) 15 | 16 | type release struct { 17 | TagName string `json:"tag_name"` 18 | Assets []releaseAsset `json:"assets"` 19 | } 20 | 21 | type releaseAsset struct { 22 | ID int64 `json:"id"` 23 | Name string `json:"name"` 24 | } 25 | 26 | // getLatestRelease returns the latest release for th given owner/repo 27 | // 28 | // https://docs.github.com/en/rest/reference/repos#get-the-latest-release 29 | func getLatestRelease(owner, repo string) (*release, error) { 30 | url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", baseURL, owner, repo) 31 | req, err := http.NewRequest(http.MethodGet, url, nil) 32 | if err != nil { 33 | return nil, errors.Wrap(err, "new http req") 34 | } 35 | req.Header.Set("Accept", "application/vnd.github.v3+json") 36 | 37 | resp, err := http.DefaultClient.Do(req) 38 | if err != nil { 39 | return nil, errors.Wrap(err, "do http req") 40 | } 41 | defer resp.Body.Close() 42 | 43 | if resp.StatusCode != 200 { 44 | return nil, errors.New("bad http code") 45 | } 46 | 47 | latest := release{} 48 | err = json.NewDecoder(resp.Body).Decode(&latest) 49 | if err != nil { 50 | return nil, errors.Wrap(err, "decode github response") 51 | } 52 | 53 | return &latest, nil 54 | } 55 | 56 | // getReleaseAsset downloads asset by it ID 57 | // 58 | // https://docs.github.com/en/rest/reference/repos#get-a-release-asset 59 | func getReleaseAsset(owner, repo string, assetID int64) (io.ReadCloser, error) { 60 | url := fmt.Sprintf("%s/repos/%s/%s/releases/assets/%d", baseURL, owner, repo, assetID) 61 | req, err := http.NewRequest(http.MethodGet, url, nil) 62 | if err != nil { 63 | return nil, errors.Wrap(err, "new http req") 64 | } 65 | req.Header.Add("Accept", "application/octet-stream") 66 | 67 | resp, err := http.DefaultClient.Do(req) 68 | if err != nil { 69 | return nil, errors.Wrap(err, "do http req") 70 | } 71 | 72 | if resp.StatusCode != 200 { 73 | if err = resp.Body.Close(); err != nil { 74 | fmt.Println("resp.Body.Close()", err) 75 | } 76 | return nil, errors.New("bad http code") 77 | } 78 | 79 | return resp.Body, nil 80 | } 81 | -------------------------------------------------------------------------------- /internal/selfupdate/updater.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "compress/gzip" 7 | "crypto/sha256" 8 | "encoding/hex" 9 | "fmt" 10 | "io" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | 15 | "github.com/Masterminds/semver/v3" 16 | "github.com/pkg/errors" 17 | ) 18 | 19 | type Updater struct { 20 | owner string 21 | repo string 22 | os string 23 | arch string 24 | version *semver.Version 25 | } 26 | 27 | func NewUpdater(owner, repo, os, arch, currVersion string) (*Updater, error) { 28 | v, err := semver.NewVersion(currVersion) 29 | if err != nil { 30 | return nil, errors.New("current version is not valid semver") 31 | } 32 | // according to replacements in .goreleaser.yml 33 | if arch == "amd64" { 34 | arch = "x86_64" 35 | } 36 | if arch == "windows" { 37 | return nil, errors.New("windows selfupdate temporarily not supported") 38 | } 39 | return &Updater{ 40 | owner: owner, 41 | repo: repo, 42 | os: os, 43 | arch: arch, 44 | version: v, 45 | }, nil 46 | } 47 | 48 | func (upd *Updater) Update() error { 49 | fmt.Printf("Looking for a new version for %s_%s\n", upd.os, upd.arch) 50 | 51 | latest, err := getLatestRelease(upd.owner, upd.repo) 52 | if err != nil { 53 | return errors.Wrap(err, "get latest release") 54 | } 55 | if len(latest.Assets) == 0 { 56 | return errors.New("no assets found for the latest releas") 57 | } 58 | need, err := upd.needUpdate(latest.TagName) 59 | if err != nil { 60 | return errors.Wrap(err, "check need for update") 61 | } 62 | if !need { 63 | fmt.Println("Ferret is up to date") 64 | return nil 65 | } 66 | 67 | fmt.Println("New version of Ferret available!") 68 | fmt.Println("Update Ferret to", latest.TagName) 69 | fmt.Println("Download checksums and assets") 70 | 71 | sha256, err := upd.downloadChecksum(latest.Assets) 72 | if err != nil { 73 | return errors.Wrap(err, "download checksum") 74 | } 75 | compressed, asset, err := upd.downloadBin(latest.Assets) 76 | if err != nil { 77 | return errors.Wrap(err, "download bin") 78 | } 79 | 80 | fmt.Println("Verify checksum") 81 | 82 | if err = verifyBin(sha256, compressed); err != nil { 83 | return errors.Wrap(err, "verify checksum") 84 | } 85 | 86 | fmt.Println("Uncompress", asset.Name) 87 | 88 | bin, err := uncompress(compressed, binType(asset.Name)) 89 | if err != nil { 90 | return errors.Wrap(err, "uncompress bin") 91 | } 92 | 93 | fmt.Println("Install new version") 94 | 95 | if err = replaceBin(bin); err != nil { 96 | return errors.Wrap(err, "replace old and new bin") 97 | } 98 | return nil 99 | } 100 | 101 | func (upd *Updater) needUpdate(latest string) (bool, error) { 102 | latestV, err := semver.NewVersion(latest) 103 | if err != nil { 104 | return false, errors.Wrap(err, "latest version is not valid semver") 105 | } 106 | return latestV.Compare(upd.version) == 1, nil 107 | } 108 | 109 | func (upd *Updater) downloadChecksum(assets []releaseAsset) (sha256 [sha256.Size]byte, err error) { 110 | assetID := int64(-1) 111 | for _, asset := range assets { 112 | if asset.Name == "cli_checksums.txt" { 113 | assetID = asset.ID 114 | } 115 | } 116 | if assetID == -1 { 117 | return sha256, errors.New("checksum asset not found") 118 | } 119 | 120 | asset, err := getReleaseAsset(upd.owner, upd.repo, assetID) 121 | if err != nil { 122 | return sha256, errors.Wrap(err, "get asset") 123 | } 124 | defer asset.Close() 125 | 126 | data, err := io.ReadAll(asset) 127 | if err != nil { 128 | return sha256, errors.Wrap(err, "read asset") 129 | } 130 | sha256, err = platformChecksum(data, upd.os, upd.arch) 131 | if err != nil { 132 | return sha256, errors.Wrap(err, "find platform checksum") 133 | } 134 | return sha256, nil 135 | } 136 | 137 | func platformChecksum(checksums []byte, os, arch string) ([sha256.Size]byte, error) { 138 | sum256 := [sha256.Size]byte{} 139 | osArch := os + "_" + arch 140 | 141 | // file `checksums` looks something like this: 142 | // sha256_hex ferret_darwin_x86_64 143 | // sha256_hex ferret_linux_arm64 144 | // ... 145 | for _, line := range bytes.Split(checksums, []byte("\n")) { 146 | if bytes.Contains(line, []byte(osArch)) { 147 | segments := bytes.Split(line, []byte(" ")) 148 | if len(segments) == 0 { 149 | return sum256, errors.New("invalid checksums file") 150 | } 151 | _, err := hex.Decode(sum256[:], segments[0]) 152 | if err != nil { 153 | return sum256, errors.Wrap(err, "invalid hex string") 154 | } 155 | return sum256, nil 156 | } 157 | } 158 | 159 | return sum256, errors.Errorf("no checksum found for %s", osArch) 160 | } 161 | 162 | func (upd *Updater) downloadBin(assets []releaseAsset) ([]byte, *releaseAsset, error) { 163 | osArch := upd.os + "_" + upd.arch 164 | binAsset := releaseAsset{} 165 | for _, asset := range assets { 166 | if strings.Contains(asset.Name, osArch) { 167 | binAsset = asset 168 | break 169 | } 170 | } 171 | if binAsset.Name == "" { 172 | return nil, nil, errors.New("bin asset not found") 173 | } 174 | 175 | rd, err := getReleaseAsset(upd.owner, upd.repo, binAsset.ID) 176 | if err != nil { 177 | return nil, nil, errors.Wrap(err, "get asset") 178 | } 179 | defer rd.Close() 180 | 181 | data, err := io.ReadAll(rd) 182 | if err != nil { 183 | return nil, nil, errors.Wrap(err, "read asset") 184 | } 185 | return data, &binAsset, nil 186 | } 187 | 188 | func verifyBin(checksum [sha256.Size]byte, bin []byte) error { 189 | sum := sha256.Sum256(bin) 190 | if sum == checksum { 191 | return nil 192 | } 193 | return errors.Errorf("Invalid checksum\n expected: %x\n actual: %x", checksum, sum) 194 | } 195 | 196 | const ( 197 | tgzType = "tgz" 198 | zipType = "zip" 199 | 200 | binName = "ferret" 201 | ) 202 | 203 | func binType(name string) string { 204 | switch { 205 | case strings.HasSuffix(name, ".tar.gz"): 206 | return tgzType 207 | default: 208 | // cut `.` at the beginning of the string 209 | return filepath.Ext(name)[1:] 210 | } 211 | } 212 | 213 | func uncompress(data []byte, contentType string) ([]byte, error) { 214 | var rd io.Reader 215 | 216 | switch contentType { 217 | case tgzType: 218 | gziprd, err := gzip.NewReader(bytes.NewReader(data)) 219 | if err != nil { 220 | return nil, errors.Wrap(err, "new gzip reader") 221 | } 222 | defer gziprd.Close() 223 | 224 | tgzrd := tar.NewReader(gziprd) 225 | for { 226 | hdr, err := tgzrd.Next() 227 | if err == io.EOF { 228 | break 229 | } 230 | if err != nil { 231 | return nil, errors.Wrap(err, "uncompress tgz") 232 | } 233 | if hdr.Name == binName { 234 | rd = tgzrd 235 | break 236 | } 237 | } 238 | 239 | case zipType: 240 | return nil, errors.New("zip files are temporarily not supported") 241 | 242 | default: 243 | return nil, errors.Errorf("unknown content type \"%s\"", contentType) 244 | } 245 | 246 | if rd == nil { 247 | return nil, errors.New("bin file not found in acrhive") 248 | } 249 | return io.ReadAll(rd) 250 | } 251 | 252 | func replaceBin(newbin []byte) error { 253 | currpath, err := os.Executable() 254 | if err != nil { 255 | return errors.Wrap(err, "get executable path") 256 | } 257 | 258 | currfile := filepath.Base(currpath) 259 | currdir := filepath.Dir(currpath) 260 | prevpath := filepath.Join(currdir, currfile+".prev") 261 | 262 | // move current bin into bin.prev 263 | err = os.Rename(currpath, prevpath) 264 | if err != nil { 265 | return errors.Wrap(err, "move current bin") 266 | } 267 | 268 | // write new bin 269 | err = os.WriteFile(currpath, newbin, 0777) 270 | if err != nil { 271 | // try to rollback 272 | if rberr := os.Rename(prevpath, currpath); rberr != nil { 273 | return errors.Wrapf(rberr, "selfupdate and rollback are failed. bin moved to %s", prevpath) 274 | } 275 | 276 | return errors.Wrap(err, "selfupdate failed. rollback done") 277 | } 278 | 279 | err = os.Remove(prevpath) 280 | if err != nil { 281 | fmt.Printf("selfupdate done, but old program is not deleted: %s\n", prevpath) 282 | } 283 | 284 | return nil 285 | } 286 | -------------------------------------------------------------------------------- /logger/level.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/rs/zerolog" 8 | ) 9 | 10 | func Levels() []string { 11 | return []string{ 12 | zerolog.DebugLevel.String(), 13 | zerolog.InfoLevel.String(), 14 | zerolog.WarnLevel.String(), 15 | zerolog.ErrorLevel.String(), 16 | zerolog.FatalLevel.String(), 17 | } 18 | } 19 | 20 | func LevelsFmt() string { 21 | return fmt.Sprintf("\"%s\"", strings.Join(Levels(), `"|"`)) 22 | } 23 | 24 | func ToLevel(input string) zerolog.Level { 25 | lvl, err := zerolog.ParseLevel(strings.ReplaceAll(strings.ToLower(input), " ", "")) 26 | 27 | if err != nil { 28 | return zerolog.InfoLevel 29 | } 30 | 31 | return lvl 32 | } 33 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/natefinch/lumberjack" 7 | "github.com/rs/zerolog" 8 | ) 9 | 10 | type Logger struct { 11 | logger *zerolog.Logger 12 | fileWriter *lumberjack.Logger 13 | } 14 | 15 | func New(opts Options) *Logger { 16 | output := &lumberjack.Logger{ 17 | Filename: opts.LogFilename, 18 | MaxSize: opts.LogMaxSize, 19 | MaxAge: opts.LogMaxAge, 20 | } 21 | 22 | l := zerolog.New(output).Level(opts.Level).With().Timestamp().Logger() 23 | 24 | logger := new(Logger) 25 | logger.fileWriter = output 26 | logger.logger = &l 27 | 28 | return logger 29 | } 30 | 31 | func (l *Logger) Log() *zerolog.Logger { 32 | return l.logger 33 | } 34 | 35 | func (l *Logger) Output() io.Writer { 36 | return l.fileWriter 37 | } 38 | -------------------------------------------------------------------------------- /logger/options.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import "github.com/rs/zerolog" 4 | 5 | type Options struct { 6 | Level zerolog.Level 7 | LogFilename string 8 | LogMaxSize int 9 | LogMaxAge int 10 | } 11 | 12 | func NewDefaultOptions() Options { 13 | return Options{ 14 | Level: zerolog.InfoLevel, 15 | LogFilename: "ferret.log", 16 | LogMaxSize: 10, // Mb 17 | LogMaxAge: 30, // Days 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /repl/repl.go: -------------------------------------------------------------------------------- 1 | package repl 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/chzyer/readline" 9 | 10 | "github.com/MontFerret/cli/runtime" 11 | ) 12 | 13 | func Start(ctx context.Context, opts runtime.Options, params map[string]interface{}) error { 14 | rt, err := runtime.New(opts) 15 | 16 | if err != nil { 17 | return err 18 | } 19 | 20 | version, err := rt.Version(ctx) 21 | 22 | if err != nil { 23 | return err 24 | } 25 | 26 | fmt.Printf("Welcome to Ferret REPL %s\n", version) 27 | fmt.Println("Please use `exit` or `Ctrl-D` to exit this program.") 28 | 29 | rl, err := readline.NewEx(&readline.Config{ 30 | Prompt: "> ", 31 | InterruptPrompt: "^C", 32 | EOFPrompt: "exit", 33 | }) 34 | 35 | if err != nil { 36 | return err 37 | } 38 | 39 | defer rl.Close() 40 | 41 | var commands []string 42 | var multiline bool 43 | 44 | ctx, cancel := context.WithCancel(context.Background()) 45 | 46 | exit := func() { 47 | cancel() 48 | } 49 | 50 | for { 51 | line, err := rl.Readline() 52 | 53 | if err != nil { 54 | break 55 | } 56 | 57 | line = strings.TrimSpace(line) 58 | 59 | if line == "" { 60 | continue 61 | } 62 | 63 | if strings.HasPrefix(line, "%") { 64 | line = line[1:] 65 | 66 | multiline = !multiline 67 | } 68 | 69 | if multiline { 70 | commands = append(commands, line) 71 | continue 72 | } 73 | 74 | commands = append(commands, line) 75 | query := strings.TrimSpace(strings.Join(commands, "\n")) 76 | commands = make([]string, 0, 10) 77 | 78 | if query == "" { 79 | continue 80 | } 81 | 82 | if query == "exit" { 83 | exit() 84 | 85 | break 86 | } 87 | 88 | out, err := rt.Run(ctx, query, params) 89 | 90 | if err != nil { 91 | fmt.Println("Failed to execute the query") 92 | fmt.Println(err) 93 | continue 94 | } 95 | 96 | fmt.Println(string(out)) 97 | } 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /revive.toml: -------------------------------------------------------------------------------- 1 | ignoreGeneratedHeader = true 2 | severity = "error" 3 | confidence = 0.8 4 | errorCode = 1 5 | warningCode = 0 6 | 7 | [rule.blank-imports] 8 | [rule.context-as-argument] 9 | [rule.context-keys-type] 10 | [rule.dot-imports] 11 | [rule.error-return] 12 | [rule.error-strings] 13 | [rule.error-naming] 14 | [rule.if-return] 15 | [rule.increment-decrement] 16 | [rule.var-naming] 17 | [rule.var-declaration] 18 | [rule.range] 19 | [rule.receiver-naming] 20 | [rule.time-naming] 21 | [rule.unexported-return] 22 | [rule.indent-error-flow] 23 | [rule.errorf] 24 | [rule.empty-block] 25 | [rule.superfluous-else] 26 | [rule.unused-parameter] 27 | [rule.unreachable-code] 28 | [rule.redefines-builtin-id] -------------------------------------------------------------------------------- /runtime/builtin.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/pkg/errors" 7 | 8 | "github.com/MontFerret/ferret" 9 | "github.com/MontFerret/ferret/pkg/drivers" 10 | "github.com/MontFerret/ferret/pkg/drivers/cdp" 11 | "github.com/MontFerret/ferret/pkg/drivers/http" 12 | "github.com/MontFerret/ferret/pkg/runtime" 13 | ) 14 | 15 | var version = "unknown" 16 | 17 | const DefaultRuntime = "builtin" 18 | const DefaultBrowser = "http://127.0.0.1:9222" 19 | 20 | type Builtin struct { 21 | opts Options 22 | compiler *ferret.Instance 23 | } 24 | 25 | func NewBuiltin(opts Options) Runtime { 26 | rt := new(Builtin) 27 | rt.opts = opts 28 | rt.compiler = ferret.New() 29 | 30 | return rt 31 | } 32 | 33 | func (rt *Builtin) Version(_ context.Context) (string, error) { 34 | return version, nil 35 | } 36 | 37 | func (rt *Builtin) Run(ctx context.Context, query string, params map[string]interface{}) ([]byte, error) { 38 | program, err := rt.compiler.Compile(query) 39 | 40 | if err != nil { 41 | return nil, errors.Wrap(err, "compile query") 42 | } 43 | 44 | ctx = drivers.WithContext( 45 | ctx, 46 | http.NewDriver(rt.opts.ToInMemory()...), 47 | drivers.AsDefault(), 48 | ) 49 | 50 | ctx = drivers.WithContext( 51 | ctx, 52 | cdp.NewDriver(rt.opts.ToCDP()...), 53 | ) 54 | 55 | return program.Run(ctx, runtime.WithParams(params)) 56 | } 57 | -------------------------------------------------------------------------------- /runtime/options.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "github.com/MontFerret/ferret/pkg/drivers" 5 | "github.com/MontFerret/ferret/pkg/drivers/cdp" 6 | "github.com/MontFerret/ferret/pkg/drivers/http" 7 | ) 8 | 9 | type Options struct { 10 | Type string 11 | Proxy string 12 | UserAgent string 13 | Headers *drivers.HTTPHeaders 14 | Cookies *drivers.HTTPCookies 15 | KeepCookies bool 16 | BrowserAddress string 17 | WithBrowser bool 18 | WithHeadlessBrowser bool 19 | } 20 | 21 | func NewDefaultOptions() Options { 22 | return Options{ 23 | Type: DefaultRuntime, 24 | BrowserAddress: cdp.DefaultAddress, 25 | Proxy: "", 26 | UserAgent: "", 27 | Headers: nil, 28 | Cookies: nil, 29 | KeepCookies: false, 30 | WithBrowser: false, 31 | WithHeadlessBrowser: false, 32 | } 33 | } 34 | 35 | func (opts *Options) ToInMemory() []http.Option { 36 | result := make([]http.Option, 0, 4) 37 | 38 | if opts.Proxy != "" { 39 | result = append(result, http.WithProxy(opts.Proxy)) 40 | } 41 | 42 | if opts.UserAgent != "" { 43 | result = append(result, http.WithUserAgent(opts.UserAgent)) 44 | } 45 | 46 | if opts.Headers != nil { 47 | result = append(result, http.WithHeaders(opts.Headers)) 48 | } 49 | 50 | if opts.Cookies != nil { 51 | result = append(result, http.WithCookies(opts.Cookies.Values())) 52 | } 53 | 54 | return result 55 | } 56 | 57 | func (opts *Options) ToCDP() []cdp.Option { 58 | result := make([]cdp.Option, 0, 6) 59 | 60 | if opts.BrowserAddress != "" { 61 | result = append(result, cdp.WithAddress(opts.BrowserAddress)) 62 | } 63 | 64 | if opts.Proxy != "" { 65 | result = append(result, cdp.WithProxy(opts.Proxy)) 66 | } 67 | 68 | if opts.UserAgent != "" { 69 | result = append(result, cdp.WithUserAgent(opts.UserAgent)) 70 | } 71 | 72 | if opts.Headers != nil { 73 | result = append(result, cdp.WithHeaders(opts.Headers)) 74 | } 75 | 76 | if opts.Cookies != nil { 77 | result = append(result, cdp.WithCookies(opts.Cookies.Values())) 78 | } 79 | 80 | if opts.KeepCookies { 81 | result = append(result, cdp.WithKeepCookies()) 82 | } 83 | 84 | return result 85 | } 86 | -------------------------------------------------------------------------------- /runtime/remote.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | type ( 15 | remoteVersion struct { 16 | Worker string `json:"worker"` 17 | Ferret string `json:"ferret"` 18 | } 19 | 20 | remoteInfo struct { 21 | IP string `json:"ip"` 22 | Version remoteVersion `json:"version"` 23 | } 24 | 25 | remoteQuery struct { 26 | Text string `json:"text"` 27 | Params map[string]interface{} `json:"params"` 28 | } 29 | 30 | Remote struct { 31 | url url.URL 32 | opts Options 33 | client *http.Client 34 | } 35 | ) 36 | 37 | func NewRemote(url url.URL, opts Options) Runtime { 38 | rt := new(Remote) 39 | rt.url = url 40 | rt.opts = opts 41 | rt.client = http.DefaultClient 42 | 43 | return rt 44 | } 45 | 46 | func (rt *Remote) Version(ctx context.Context) (string, error) { 47 | data, err := rt.makeRequest(ctx, "GET", "/info", nil) 48 | 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | info := remoteInfo{} 54 | 55 | if err := json.Unmarshal(data, &info); err != nil { 56 | return "", errors.Wrap(err, "deserialize response data") 57 | } 58 | 59 | return info.Version.Ferret, nil 60 | } 61 | 62 | func (rt *Remote) Run(ctx context.Context, query string, params map[string]interface{}) ([]byte, error) { 63 | body, err := json.Marshal(&remoteQuery{ 64 | Text: query, 65 | Params: params, 66 | }) 67 | 68 | if err != nil { 69 | return nil, errors.Wrap(err, "serialize query") 70 | } 71 | 72 | return rt.makeRequest(ctx, "POST", "/", body) 73 | } 74 | 75 | func (rt *Remote) createRequest(ctx context.Context, method, endpoint string, body []byte) (*http.Request, error) { 76 | var reader io.Reader 77 | 78 | if body != nil { 79 | reader = bytes.NewReader(body) 80 | } 81 | 82 | u2, err := url.Parse(endpoint) 83 | 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | req, err := http.NewRequestWithContext(ctx, method, rt.url.ResolveReference(u2).String(), reader) 89 | 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | if rt.opts.Headers != nil { 95 | rt.opts.Headers.ForEach(func(value []string, key string) bool { 96 | for _, v := range value { 97 | req.Header.Add(key, v) 98 | } 99 | 100 | return true 101 | }) 102 | } 103 | 104 | req.Header.Set("Content-Type", "application/jsonw") 105 | 106 | return req, nil 107 | } 108 | 109 | func (rt *Remote) makeRequest(ctx context.Context, method, endpoint string, body []byte) ([]byte, error) { 110 | req, err := rt.createRequest(ctx, method, endpoint, body) 111 | 112 | if err != nil { 113 | return nil, errors.Wrap(err, "create request") 114 | } 115 | 116 | resp, err := rt.client.Do(req) 117 | 118 | if err != nil { 119 | return nil, errors.Wrap(err, "make HTTP request to remote runtime") 120 | } 121 | 122 | defer resp.Body.Close() 123 | 124 | data, err := io.ReadAll(resp.Body) 125 | 126 | if err != nil { 127 | return nil, errors.Wrap(err, "read response data") 128 | } 129 | 130 | return data, nil 131 | } 132 | -------------------------------------------------------------------------------- /runtime/runtime.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "context" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | type Runtime interface { 12 | Version(ctx context.Context) (string, error) 13 | 14 | Run(ctx context.Context, query string, params map[string]interface{}) ([]byte, error) 15 | } 16 | 17 | func New(opts Options) (Runtime, error) { 18 | name := strings.ReplaceAll(strings.ToLower(opts.Type), " ", "") 19 | 20 | if name == DefaultRuntime { 21 | return NewBuiltin(opts), nil 22 | } 23 | 24 | u, err := url.Parse(name) 25 | 26 | if err != nil { 27 | return nil, errors.Wrap(err, "parse url") 28 | } 29 | 30 | return NewRemote(*u, opts), nil 31 | } 32 | 33 | func Run(ctx context.Context, opts Options, query string, params map[string]interface{}) ([]byte, error) { 34 | rt, err := New(opts) 35 | 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | return rt.Run(ctx, query, params) 41 | } 42 | -------------------------------------------------------------------------------- /versions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ $1 = 'ferret' ]; then 4 | echo $(cat go.mod | grep 'github.com/MontFerret/ferret v' | awk -F 'v' '{print $2}') 5 | else 6 | echo $(git describe --tags --always --dirty) 7 | fi --------------------------------------------------------------------------------