├── .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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
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
--------------------------------------------------------------------------------