├── .github
└── workflows
│ └── main.yml
├── LICENSE
├── README.md
├── checks
└── checks.go
├── client
├── auth.go
└── lessons.go
├── cmd
├── configure.go
├── login.go
├── logout.go
├── root.go
├── run.go
├── submit.go
└── upgrade.go
├── go.mod
├── go.sum
├── main.go
├── main_windows.go
├── render
└── render.go
├── version.txt
└── version
├── context.go
└── version.go
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Tag Releases
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'main'
7 | paths:
8 | - 'version.txt'
9 |
10 | # Allows you to run this workflow manually from the Actions tab
11 | workflow_dispatch:
12 |
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/checkout@v3
19 | - run: |
20 | VERSION="$(cat version.txt)"
21 | git tag "$VERSION"
22 | git push origin tag "$VERSION"
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright © 2024 Ogdolo LLC
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # Bootdev CLI
6 |
7 | The official command line tool for [Boot.dev](https://www.boot.dev). It allows you to submit lessons and do other such nonsense.
8 |
9 | ⭐ Hit the repo with a star if you're enjoying Boot.dev ⭐
10 |
11 | ## Installation
12 |
13 | ### 1. Install Go 1.22 or later
14 |
15 | The Boot.dev CLI requires a Golang installation, and only works on Linux and Mac. If you're on Windows, you'll need to use WSL. Make sure you install go in your Linux/WSL terminal, not your Windows terminal/UI. There are two options:
16 |
17 | **Option 1**: [The webi installer](https://webinstall.dev/golang/) is the simplest way for most people. Just run this in your terminal:
18 |
19 | ```bash
20 | curl -sS https://webi.sh/golang | sh
21 | ```
22 |
23 | _Read the output of the command and follow any instructions._
24 |
25 | **Option 2**: Use the [official installation instructions](https://go.dev/doc/install).
26 |
27 | Run `go version` on your command line to make sure the installation worked. If it did, _move on to step 2_.
28 |
29 | **Optional troubleshooting:**
30 |
31 | - If you already had Go installed with webi, you should be able to run the same webi command to update it.
32 | - If you already had a version of Go installed a different way, you can use `which go` to find out where it is installed, and remove the old version manually.
33 | - If you're getting a "command not found" error after installation, it's most likely because the directory containing the `go` program isn't in your [`PATH`](https://opensource.com/article/17/6/set-path-linux). You need to add the directory to your `PATH` by modifying your shell's configuration file. First, you need to know _where_ the `go` command was installed. It might be in:
34 |
35 | - `~/.local/opt/go/bin` (webi)
36 | - `/usr/local/go/bin` (official installation)
37 | - Somewhere else?
38 |
39 | You can ensure it exists by attempting to run `go` using its full filepath. For example, if you think it's in `~/.local/opt/go/bin`, you can run `~/.local/opt/go/bin/go version`. If that works, then you just need to add `~/.local/opt/go/bin` to your `PATH` and reload your shell:
40 |
41 | ```bash
42 | # For Linux/WSL
43 | echo 'export PATH=$PATH:$HOME/.local/opt/go/bin' >> ~/.bashrc
44 | # next, reload your shell configuration
45 | source ~/.bashrc
46 | ```
47 |
48 | ```bash
49 | # For Mac OS
50 | echo 'export PATH=$PATH:$HOME/.local/opt/go/bin' >> ~/.zshrc
51 | # next, reload your shell configuration
52 | source ~/.zshrc
53 | ```
54 |
55 | ### 2. Install the Boot.dev CLI
56 |
57 | This command will download, build, and install the `bootdev` command into your Go toolchain's `bin` directory. Go ahead and run it:
58 |
59 | ```bash
60 | go install github.com/bootdotdev/bootdev@latest
61 | ```
62 |
63 | Run `bootdev --version` on your command line to make sure the installation worked. If it did, _move on to step 3_.
64 |
65 | **Optional troubleshooting:**
66 |
67 | If you're getting a "command not found" error for `bootdev help`, it's most likely because the directory containing the `bootdev` program isn't in your [`PATH`](https://opensource.com/article/17/6/set-path-linux). You need to add the directory to your `PATH` by modifying your shell's configuration file. You probably need to add `$HOME/go/bin` (the default `GOBIN` directory where `go` installs programs) to your `PATH`:
68 |
69 | ```bash
70 | # For Linux/WSL
71 | echo 'export PATH=$PATH:$HOME/go/bin' >> ~/.bashrc
72 | # next, reload your shell configuration
73 | source ~/.bashrc
74 | ```
75 |
76 | ```bash
77 | # For Mac OS
78 | echo 'export PATH=$PATH:$HOME/go/bin' >> ~/.zshrc
79 | # next, reload your shell configuration
80 | source ~/.zshrc
81 | ```
82 |
83 | ### 3. Login to the CLI
84 |
85 | Run `bootdev login` to authenticate with your Boot.dev account. After authenticating, you're ready to go!
86 |
87 | ## Configuration
88 |
89 | The Boot.dev CLI offers a couple of configuration options that are stored in a config file (default is `~/.bootdev.yaml`).
90 |
91 | All commands have `-h`/`--help` flags if you want to see available options on the command line.
92 |
93 | ### Base URL for HTTP tests
94 |
95 | For lessons with HTTP tests, you can configure the CLI with a base URL that overrides any lesson's default. A common use case for that is when you want to run your server on a port other than the one specified in the lesson.
96 |
97 | - To set the base URL run:
98 |
99 | ```bash
100 | bootdev config base_url
101 | ```
102 |
103 | *Make sure you include the protocol scheme (`http://`) in the URL.*
104 |
105 | - To get the current base URL (the default is an empty string), run:
106 |
107 | ```bash
108 | bootdev config base_url
109 | ```
110 |
111 | - To reset the base URL and revert to using the lessons' defaults, run:
112 |
113 | ```bash
114 | bootdev config base_url --reset
115 | ```
116 |
117 | ### CLI colors
118 |
119 | The CLI text output is rendered with extra colors: green (e.g., success messages), red (e.g., error messages), and gray (e.g., secondary text).
120 |
121 | - To customize these colors, run:
122 |
123 | ```bash
124 | bootdev config colors --red --green --gray
125 | ```
126 |
127 | *You can use an [ANSI color code](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) or a hex string as the ``.*
128 |
129 | - To get the current colors, run:
130 |
131 | ```bash
132 | bootdev config colors
133 | ```
134 |
135 | - To reset the colors to their default values, run:
136 |
137 | ```bash
138 | bootdev config colors --reset
139 | ```
140 |
--------------------------------------------------------------------------------
/checks/checks.go:
--------------------------------------------------------------------------------
1 | package checks
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | "os"
11 | "os/exec"
12 | "regexp"
13 | "strings"
14 | "time"
15 |
16 | api "github.com/bootdotdev/bootdev/client"
17 | "github.com/itchyny/gojq"
18 | "github.com/spf13/cobra"
19 | )
20 |
21 | func runCLICommand(command api.CLIStepCLICommand, variables map[string]string) (result api.CLICommandResult) {
22 | finalCommand := InterpolateVariables(command.Command, variables)
23 | result.FinalCommand = finalCommand
24 |
25 | cmd := exec.Command("sh", "-c", finalCommand)
26 | cmd.Env = append(os.Environ(), "LANG=en_US.UTF-8")
27 | b, err := cmd.CombinedOutput()
28 | if ee, ok := err.(*exec.ExitError); ok {
29 | result.ExitCode = ee.ExitCode()
30 | } else if err != nil {
31 | result.ExitCode = -2
32 | }
33 | result.Stdout = strings.TrimRight(string(b), " \n\t\r")
34 | result.Variables = variables
35 | return result
36 | }
37 |
38 | func runHTTPRequest(
39 | client *http.Client,
40 | baseURL string,
41 | variables map[string]string,
42 | requestStep api.CLIStepHTTPRequest,
43 | ) (
44 | result api.HTTPRequestResult,
45 | ) {
46 | finalBaseURL := strings.TrimSuffix(baseURL, "/")
47 | interpolatedURL := InterpolateVariables(requestStep.Request.FullURL, variables)
48 | completeURL := strings.Replace(interpolatedURL, api.BaseURLPlaceholder, finalBaseURL, 1)
49 |
50 | var req *http.Request
51 | if requestStep.Request.BodyJSON != nil {
52 | dat, err := json.Marshal(requestStep.Request.BodyJSON)
53 | cobra.CheckErr(err)
54 | interpolatedBodyJSONStr := InterpolateVariables(string(dat), variables)
55 | req, err = http.NewRequest(requestStep.Request.Method, completeURL,
56 | bytes.NewBuffer([]byte(interpolatedBodyJSONStr)),
57 | )
58 | if err != nil {
59 | cobra.CheckErr("Failed to create request")
60 | }
61 | req.Header.Add("Content-Type", "application/json")
62 | } else {
63 | var err error
64 | req, err = http.NewRequest(requestStep.Request.Method, completeURL, nil)
65 | if err != nil {
66 | cobra.CheckErr("Failed to create request")
67 | }
68 | }
69 |
70 | for k, v := range requestStep.Request.Headers {
71 | req.Header.Add(k, InterpolateVariables(v, variables))
72 | }
73 |
74 | if requestStep.Request.BasicAuth != nil {
75 | req.SetBasicAuth(requestStep.Request.BasicAuth.Username, requestStep.Request.BasicAuth.Password)
76 | }
77 |
78 | if requestStep.Request.Actions.DelayRequestByMs != nil {
79 | time.Sleep(time.Duration(*requestStep.Request.Actions.DelayRequestByMs) * time.Millisecond)
80 | }
81 |
82 | resp, err := client.Do(req)
83 | if err != nil {
84 | errString := fmt.Sprintf("Failed to fetch: %s", err.Error())
85 | result = api.HTTPRequestResult{Err: errString}
86 | return result
87 | }
88 | defer resp.Body.Close()
89 |
90 | body, err := io.ReadAll(resp.Body)
91 | if err != nil {
92 | result = api.HTTPRequestResult{Err: "Failed to read response body"}
93 | return result
94 | }
95 |
96 | headers := make(map[string]string)
97 | for k, v := range resp.Header {
98 | headers[k] = strings.Join(v, ",")
99 | }
100 |
101 | trailers := make(map[string]string)
102 | for k, v := range resp.Trailer {
103 | trailers[k] = strings.Join(v, ",")
104 | }
105 |
106 | parseVariables(body, requestStep.ResponseVariables, variables)
107 |
108 | result = api.HTTPRequestResult{
109 | StatusCode: resp.StatusCode,
110 | ResponseHeaders: headers,
111 | ResponseTrailers: trailers,
112 | BodyString: truncateAndStringifyBody(body),
113 | Variables: variables,
114 | Request: requestStep,
115 | }
116 | return result
117 | }
118 |
119 | func CLIChecks(cliData api.CLIData, overrideBaseURL string) (results []api.CLIStepResult) {
120 | client := &http.Client{}
121 | variables := make(map[string]string)
122 | results = make([]api.CLIStepResult, len(cliData.Steps))
123 |
124 | if cliData.BaseURLDefault == api.BaseURLOverrideRequired && overrideBaseURL == "" {
125 | cobra.CheckErr("lesson requires a base URL override - bootdev configure base_url ")
126 | }
127 |
128 | // prefer overrideBaseURL if provided, otherwise use BaseURLDefault
129 | baseURL := overrideBaseURL
130 | if overrideBaseURL == "" {
131 | baseURL = cliData.BaseURLDefault
132 | }
133 |
134 | for i, step := range cliData.Steps {
135 | switch {
136 | case step.CLICommand != nil:
137 | result := runCLICommand(*step.CLICommand, variables)
138 | results[i].CLICommandResult = &result
139 | case step.HTTPRequest != nil:
140 | result := runHTTPRequest(client, baseURL, variables, *step.HTTPRequest)
141 | results[i].HTTPRequestResult = &result
142 | if result.Variables != nil {
143 | variables = result.Variables
144 | }
145 | default:
146 | cobra.CheckErr("unable to run lesson: missing step")
147 | }
148 | }
149 | return results
150 | }
151 |
152 | // truncateAndStringifyBody
153 | // in some lessons we yeet the entire body up to the server, but we really shouldn't ever care
154 | // about more than 100,000 stringified characters of it, so this protects against giant bodies
155 | func truncateAndStringifyBody(body []byte) string {
156 | bodyString := string(body)
157 | const maxBodyLength = 1000000
158 | if len(bodyString) > maxBodyLength {
159 | bodyString = bodyString[:maxBodyLength]
160 | }
161 | return bodyString
162 | }
163 |
164 | func parseVariables(body []byte, vardefs []api.HTTPRequestResponseVariable, variables map[string]string) error {
165 | for _, vardef := range vardefs {
166 | val, err := valFromJQPath(vardef.Path, string(body))
167 | if err != nil {
168 | return err
169 | }
170 | variables[vardef.Name] = fmt.Sprintf("%v", val)
171 | }
172 | return nil
173 | }
174 |
175 | func valFromJQPath(path string, jsn string) (any, error) {
176 | vals, err := valsFromJQPath(path, jsn)
177 | if err != nil {
178 | return nil, err
179 | }
180 | if len(vals) != 1 {
181 | return nil, errors.New("invalid number of values found")
182 | }
183 | val := vals[0]
184 | if val == nil {
185 | return nil, errors.New("value not found")
186 | }
187 | return val, nil
188 | }
189 |
190 | func valsFromJQPath(path string, jsn string) ([]any, error) {
191 | var parseable any
192 | err := json.Unmarshal([]byte(jsn), &parseable)
193 | if err != nil {
194 | return nil, err
195 | }
196 |
197 | query, err := gojq.Parse(path)
198 | if err != nil {
199 | return nil, err
200 | }
201 | iter := query.Run(parseable)
202 | vals := []any{}
203 | for {
204 | v, ok := iter.Next()
205 | if !ok {
206 | break
207 | }
208 | if err, ok := v.(error); ok {
209 | if err, ok := err.(*gojq.HaltError); ok && err.Value() == nil {
210 | break
211 | }
212 | return nil, err
213 | }
214 | vals = append(vals, v)
215 | }
216 | return vals, nil
217 | }
218 |
219 | func InterpolateVariables(template string, vars map[string]string) string {
220 | r := regexp.MustCompile(`\$\{([^}]+)\}`)
221 | return r.ReplaceAllStringFunc(template, func(m string) string {
222 | // Extract the key from the match, which is in the form ${key}
223 | key := strings.TrimSuffix(strings.TrimPrefix(m, "${"), "}")
224 | if val, ok := vars[key]; ok {
225 | return val
226 | }
227 | return m // return the original placeholder if no substitution found
228 | })
229 | }
230 |
--------------------------------------------------------------------------------
/client/auth.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "io"
9 | "net/http"
10 |
11 | "github.com/spf13/viper"
12 | )
13 |
14 | type LoginRequest struct {
15 | Otp string `json:"otp"`
16 | }
17 |
18 | type LoginResponse struct {
19 | AccessToken string `json:"access_token"`
20 | RefreshToken string `json:"refresh_token"`
21 | }
22 |
23 | func FetchAccessToken() (*LoginResponse, error) {
24 | api_url := viper.GetString("api_url")
25 | client := &http.Client{}
26 | r, err := http.NewRequest("POST", api_url+"/v1/auth/refresh", bytes.NewBuffer([]byte{}))
27 | r.Header.Add("X-Refresh-Token", viper.GetString("refresh_token"))
28 | if err != nil {
29 | return nil, err
30 | }
31 | resp, err := client.Do(r)
32 | if err != nil {
33 | return nil, err
34 | }
35 | defer resp.Body.Close()
36 |
37 | if resp.StatusCode != 200 {
38 | return nil, errors.New("invalid refresh token")
39 | }
40 |
41 | body, err := io.ReadAll(resp.Body)
42 | if err != nil {
43 | return nil, err
44 | }
45 |
46 | var creds LoginResponse
47 | err = json.Unmarshal(body, &creds)
48 | return &creds, err
49 | }
50 |
51 | func LoginWithCode(code string) (*LoginResponse, error) {
52 | api_url := viper.GetString("api_url")
53 | req, err := json.Marshal(LoginRequest{Otp: code})
54 | if err != nil {
55 | return nil, err
56 | }
57 |
58 | resp, err := http.Post(api_url+"/v1/auth/otp/login", "application/json", bytes.NewReader(req))
59 | if err != nil {
60 | return nil, err
61 | }
62 |
63 | if resp.StatusCode == 403 {
64 | return nil, errors.New("invalid login code, please refresh your browser then try again")
65 | }
66 |
67 | if resp.StatusCode != 200 {
68 | return nil, errors.New(resp.Status)
69 | }
70 |
71 | body, err := io.ReadAll(resp.Body)
72 | if err != nil {
73 | return nil, err
74 | }
75 |
76 | var creds LoginResponse
77 | err = json.Unmarshal(body, &creds)
78 | if err != nil {
79 | return nil, err
80 | }
81 |
82 | return &creds, nil
83 | }
84 |
85 | func fetchWithAuth(method string, url string) ([]byte, error) {
86 | body, code, err := fetchWithAuthAndPayload(method, url, []byte{})
87 | if err != nil {
88 | return nil, err
89 | }
90 | if code != 200 {
91 | return nil, fmt.Errorf("failed to %s to %s\nResponse: %d %s", method, url, code, string(body))
92 | }
93 | return body, err
94 | }
95 |
96 | func fetchWithAuthAndPayload(method string, url string, payload []byte) ([]byte, int, error) {
97 | api_url := viper.GetString("api_url")
98 | client := &http.Client{}
99 | r, err := http.NewRequest(method, api_url+url, bytes.NewBuffer(payload))
100 | if err != nil {
101 | return nil, 0, err
102 | }
103 | r.Header.Add("Authorization", "Bearer "+viper.GetString("access_token"))
104 |
105 | resp, err := client.Do(r)
106 | if err != nil {
107 | return nil, 0, err
108 | }
109 | defer resp.Body.Close()
110 |
111 | body, err := io.ReadAll(resp.Body)
112 | if err != nil {
113 | return nil, 0, err
114 | }
115 |
116 | return body, resp.StatusCode, nil
117 | }
118 |
--------------------------------------------------------------------------------
/client/lessons.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | )
7 |
8 | type Lesson struct {
9 | Lesson struct {
10 | Type string
11 | LessonDataCLI *LessonDataCLI
12 | }
13 | }
14 |
15 | type LessonDataCLI struct {
16 | // Readme string
17 | CLIData CLIData
18 | }
19 |
20 | const BaseURLOverrideRequired = "override"
21 |
22 | type CLIData struct {
23 | // ContainsCompleteDir bool
24 | BaseURLDefault string
25 | Steps []CLIStep
26 | }
27 |
28 | type CLIStep struct {
29 | CLICommand *CLIStepCLICommand
30 | HTTPRequest *CLIStepHTTPRequest
31 | }
32 |
33 | type CLIStepCLICommand struct {
34 | Command string
35 | Tests []CLICommandTest
36 | }
37 |
38 | type CLICommandTest struct {
39 | ExitCode *int
40 | StdoutContainsAll []string
41 | StdoutContainsNone []string
42 | StdoutLinesGt *int
43 | }
44 |
45 | type CLIStepHTTPRequest struct {
46 | ResponseVariables []HTTPRequestResponseVariable
47 | Tests []HTTPRequestTest
48 | Request HTTPRequest
49 | }
50 |
51 | const BaseURLPlaceholder = "${baseURL}"
52 |
53 | type HTTPRequest struct {
54 | Method string
55 | FullURL string
56 | Headers map[string]string
57 | BodyJSON map[string]any
58 |
59 | BasicAuth *HTTPBasicAuth
60 | Actions HTTPActions
61 | }
62 |
63 | type HTTPBasicAuth struct {
64 | Username string
65 | Password string
66 | }
67 |
68 | type HTTPActions struct {
69 | DelayRequestByMs *int
70 | }
71 |
72 | type HTTPRequestResponseVariable struct {
73 | Name string
74 | Path string
75 | }
76 |
77 | // Only one of these fields should be set
78 | type HTTPRequestTest struct {
79 | StatusCode *int
80 | BodyContains *string
81 | BodyContainsNone *string
82 | HeadersContain *HTTPRequestTestHeader
83 | TrailersContain *HTTPRequestTestHeader
84 | JSONValue *HTTPRequestTestJSONValue
85 | }
86 |
87 | type HTTPRequestTestHeader struct {
88 | Key string
89 | Value string
90 | }
91 |
92 | type HTTPRequestTestJSONValue struct {
93 | Path string
94 | Operator OperatorType
95 | IntValue *int
96 | StringValue *string
97 | BoolValue *bool
98 | }
99 |
100 | type OperatorType string
101 |
102 | const (
103 | OpEquals OperatorType = "eq"
104 | OpGreaterThan OperatorType = "gt"
105 | OpContains OperatorType = "contains"
106 | OpNotContains OperatorType = "not_contains"
107 | )
108 |
109 | func FetchLesson(uuid string) (*Lesson, error) {
110 | resp, err := fetchWithAuth("GET", "/v1/static/lessons/"+uuid)
111 | if err != nil {
112 | return nil, err
113 | }
114 |
115 | var data Lesson
116 | err = json.Unmarshal(resp, &data)
117 | if err != nil {
118 | return nil, err
119 | }
120 | return &data, nil
121 | }
122 |
123 | type CLIStepResult struct {
124 | CLICommandResult *CLICommandResult
125 | HTTPRequestResult *HTTPRequestResult
126 | }
127 |
128 | type CLICommandResult struct {
129 | ExitCode int
130 | FinalCommand string `json:"-"`
131 | Stdout string
132 | Variables map[string]string
133 | }
134 |
135 | type HTTPRequestResult struct {
136 | Err string `json:"-"`
137 | StatusCode int
138 | ResponseHeaders map[string]string
139 | ResponseTrailers map[string]string
140 | BodyString string
141 | Variables map[string]string
142 | Request CLIStepHTTPRequest
143 | }
144 |
145 | type lessonSubmissionCLI struct {
146 | CLIResults []CLIStepResult
147 | }
148 |
149 | type StructuredErrCLI struct {
150 | ErrorMessage string `json:"Error"`
151 | FailedStepIndex int `json:"FailedStepIndex"`
152 | FailedTestIndex int `json:"FailedTestIndex"`
153 | }
154 |
155 | func SubmitCLILesson(uuid string, results []CLIStepResult) (*StructuredErrCLI, error) {
156 | bytes, err := json.Marshal(lessonSubmissionCLI{CLIResults: results})
157 | if err != nil {
158 | return nil, err
159 | }
160 | endpoint := fmt.Sprintf("/v1/lessons/%v/", uuid)
161 | resp, code, err := fetchWithAuthAndPayload("POST", endpoint, bytes)
162 | if err != nil {
163 | return nil, err
164 | }
165 | if code != 200 {
166 | return nil, fmt.Errorf("failed to submit CLI lesson (code: %v): %s", code, string(resp))
167 | }
168 | var failure StructuredErrCLI
169 | err = json.Unmarshal(resp, &failure)
170 | if err != nil || failure.ErrorMessage == "" {
171 | // this is ok - it means we had success
172 | return nil, nil
173 | }
174 | return &failure, nil
175 | }
176 |
--------------------------------------------------------------------------------
/cmd/configure.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "net/url"
6 |
7 | "github.com/charmbracelet/lipgloss"
8 | "github.com/spf13/cobra"
9 | "github.com/spf13/viper"
10 | )
11 |
12 | // configureCmd represents the configure command which is a container for other
13 | // sub-commands (e.g., colors, base URL override)
14 | var configureCmd = &cobra.Command{
15 | Use: "config",
16 | Aliases: []string{"configure"},
17 | Short: "Change configuration of the CLI",
18 | }
19 |
20 | var defaultColors = map[string]string{
21 | "gray": "8",
22 | "red": "1",
23 | "green": "2",
24 | }
25 |
26 | // configureColorsCmd represents the `configure colors` command for changing
27 | // the colors of the text output
28 | var configureColorsCmd = &cobra.Command{
29 | Use: "colors",
30 | Short: "Get or set the CLI text colors",
31 | RunE: func(cmd *cobra.Command, args []string) error {
32 | resetColors, err := cmd.Flags().GetBool("reset")
33 | if err != nil {
34 | return fmt.Errorf("couldn't get the reset flag value: %v", err)
35 | }
36 |
37 | if resetColors {
38 | for color, defaultVal := range defaultColors {
39 | viper.Set("color."+color, defaultVal)
40 | }
41 |
42 | err := viper.WriteConfig()
43 | if err != nil {
44 | return fmt.Errorf("failed to write config: %v", err)
45 | }
46 |
47 | fmt.Println("Reset colors!")
48 | return err
49 | }
50 |
51 | configColors := map[string]string{}
52 | for color := range defaultColors {
53 | configVal, err := cmd.Flags().GetString(color)
54 | if err != nil {
55 | return fmt.Errorf("couldn't get the %v flag value: %v", color, err)
56 | }
57 |
58 | configColors[color] = configVal
59 | }
60 |
61 | noFlags := true
62 | for color, configVal := range configColors {
63 | if configVal == "" {
64 | continue
65 | }
66 |
67 | noFlags = false
68 | key := "color." + color
69 | viper.Set(key, configVal)
70 | style := lipgloss.NewStyle().Foreground(lipgloss.Color(configVal))
71 | fmt.Println("set " + style.Render(key) + "!")
72 | }
73 |
74 | if noFlags {
75 | for color := range configColors {
76 | val := viper.GetString("color." + color)
77 | style := lipgloss.NewStyle().Foreground(lipgloss.Color(val))
78 | fmt.Printf(style.Render("%v: %v")+"\n", color, val)
79 | }
80 | return nil
81 | }
82 |
83 | err = viper.WriteConfig()
84 | if err != nil {
85 | return fmt.Errorf("failed to write config: %v", err)
86 | }
87 | return err
88 | },
89 | }
90 |
91 | // configureBaseURLCmd represents the `configure base_url` command
92 | var configureBaseURLCmd = &cobra.Command{
93 | Use: "base_url [url]",
94 | Short: "Get or set the base URL for HTTP tests, overriding lesson defaults",
95 | Args: cobra.RangeArgs(0, 1),
96 | RunE: func(cmd *cobra.Command, args []string) error {
97 | resetOverrideBaseURL, err := cmd.Flags().GetBool("reset")
98 | if err != nil {
99 | return fmt.Errorf("couldn't get the reset flag value: %v", err)
100 | }
101 |
102 | if resetOverrideBaseURL {
103 | viper.Set("override_base_url", "")
104 | err := viper.WriteConfig()
105 | if err != nil {
106 | return fmt.Errorf("failed to write config: %v", err)
107 | }
108 | fmt.Println("Reset base URL!")
109 | return err
110 | }
111 |
112 | if len(args) == 0 {
113 | baseURL := viper.GetString("override_base_url")
114 | message := fmt.Sprintf("Base URL: %s", baseURL)
115 | if baseURL == "" {
116 | message = "No base URL set"
117 | }
118 | fmt.Println(message)
119 | return nil
120 | }
121 |
122 | overrideBaseURL, err := url.Parse(args[0])
123 | if err != nil {
124 | return fmt.Errorf("failed to parse base URL: %v", err)
125 | }
126 | // for urls like "localhost:8080" the parser reads "localhost" into
127 | // `Scheme` and leaves `Host` as an empty string, so we must check for
128 | // both
129 | if overrideBaseURL.Scheme == "" || overrideBaseURL.Host == "" {
130 | return fmt.Errorf("invalid URL: provide both protocol scheme and hostname")
131 | }
132 | if overrideBaseURL.Scheme == "https" {
133 | fmt.Println("warning: protocol scheme is set to https")
134 | }
135 |
136 | viper.Set("override_base_url", overrideBaseURL.String())
137 | err = viper.WriteConfig()
138 | if err != nil {
139 | return fmt.Errorf("failed to write config: %v", err)
140 | }
141 | fmt.Printf("Base URL set to %v\n", overrideBaseURL.String())
142 | return err
143 | },
144 | }
145 |
146 | func init() {
147 | rootCmd.AddCommand(configureCmd)
148 |
149 | configureCmd.AddCommand(configureBaseURLCmd)
150 | configureBaseURLCmd.Flags().Bool("reset", false, "reset the base URL to use the lesson's defaults")
151 |
152 | configureCmd.AddCommand(configureColorsCmd)
153 | configureColorsCmd.Flags().Bool("reset", false, "reset colors to their default values")
154 | for color, defaultVal := range defaultColors {
155 | configureColorsCmd.Flags().String(color, "", "ANSI number or hex string")
156 | viper.SetDefault("color."+color, defaultVal)
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/cmd/login.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "bufio"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "os"
10 | "regexp"
11 | "strings"
12 | "time"
13 |
14 | api "github.com/bootdotdev/bootdev/client"
15 | "github.com/charmbracelet/lipgloss"
16 | "github.com/pkg/browser"
17 | "github.com/spf13/cobra"
18 | "github.com/spf13/viper"
19 | "golang.org/x/term"
20 | )
21 |
22 | func logoRenderer() string {
23 | blue := lipgloss.NewStyle().Foreground(lipgloss.Color("#7e88f7"))
24 | gray := lipgloss.NewStyle().Foreground(lipgloss.Color("#7e7e81"))
25 | white := lipgloss.NewStyle().Foreground(lipgloss.Color("#d9d9de"))
26 | var output string
27 | var result string
28 | var prev rune
29 | for _, c := range logo {
30 | if c == ' ' {
31 | result += string(c)
32 | continue
33 | }
34 | if prev != c {
35 | if len(result) > 0 {
36 | text := strings.ReplaceAll(result, "B", "@")
37 | text = strings.ReplaceAll(text, "D", "@")
38 | switch result[0] {
39 | case 'B':
40 | output += white.Render(text)
41 | case 'D':
42 | output += blue.Render(text)
43 | default:
44 | output += gray.Render(text)
45 | }
46 | }
47 | result = ""
48 | }
49 | result += string(c)
50 | prev = c
51 | }
52 | return output
53 | }
54 |
55 | const logo string = `
56 | @@@@ @@@@
57 | @@@@@@@@@@@ @@@@@@@ @@@@ @@@@@@@ @@@@@@@@@@@
58 | @@@ @@@@ @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@ @@@@ @@@@
59 | @@@ ... .. . @@@
60 | @@@ BBBBBBB DDDDDDDD . @@@
61 | @@@ . BB BB BBBB BBBB BBBBBBBB DD DD DDDDDD DDD DDD @@@
62 | @@@ .. BBBBBB BB BB BB BB B BB B DD DD DD DD .DD @@@@
63 | @@@ .. BB BB BB BB BB BB BB DD DD DDDD DD DD @@@@
64 | @@@ . BB BB BB BB BB BB BB DD DD DD DDD @@@
65 | @@@ BBBBBBB BBBB BBBB BB BB DDDDDDDD DDDDDD D .. @@@
66 | @@@ . ..@@@
67 | @@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@
68 | @@@@@@ @@@@@@
69 | @ @`
70 |
71 | var loginCmd = &cobra.Command{
72 | Use: "login",
73 | Aliases: []string{"auth", "authenticate", "signin"},
74 | Short: "Authenticate the CLI with your account",
75 | SilenceUsage: true,
76 | PreRun: requireUpdated,
77 | RunE: func(cmd *cobra.Command, args []string) error {
78 | w, _, err := term.GetSize(0)
79 | if err != nil {
80 | w = 0
81 | }
82 | // Pad the logo with whitespace
83 | welcome := lipgloss.PlaceHorizontal(lipgloss.Width(logo), lipgloss.Center, "Welcome to the boot.dev CLI!")
84 |
85 | if w >= lipgloss.Width(welcome) {
86 | fmt.Println(logoRenderer())
87 | fmt.Print(welcome, "\n\n")
88 | } else {
89 | fmt.Print("Welcome to the boot.dev CLI!\n\n")
90 | }
91 |
92 | loginUrl := viper.GetString("frontend_url") + "/cli/login"
93 |
94 | fmt.Println("Please navigate to:\n" + loginUrl)
95 |
96 | inputChan := make(chan string)
97 |
98 | go func() {
99 | reader := bufio.NewReader(os.Stdin)
100 | fmt.Print("\nPaste your login code: ")
101 | text, _ := reader.ReadString('\n')
102 | inputChan <- text
103 | }()
104 |
105 | go func() {
106 | startHTTPServer(inputChan)
107 | }()
108 |
109 | // attempt to open the browser
110 | go func() {
111 | browser.Stdout = nil
112 | browser.Stderr = nil
113 | browser.OpenURL(loginUrl)
114 | }()
115 |
116 | // race the web server against the user's input
117 | text := <-inputChan
118 |
119 | re := regexp.MustCompile(`[^A-Za-z0-9_-]`)
120 | text = re.ReplaceAllString(text, "")
121 | creds, err := api.LoginWithCode(text)
122 | if err != nil {
123 | return err
124 | }
125 |
126 | if creds.AccessToken == "" || creds.RefreshToken == "" {
127 | return errors.New("invalid credentials received")
128 | }
129 |
130 | viper.Set("access_token", creds.AccessToken)
131 | viper.Set("refresh_token", creds.RefreshToken)
132 | viper.Set("last_refresh", time.Now().Unix())
133 |
134 | err = viper.WriteConfig()
135 | if err != nil {
136 | return err
137 | }
138 |
139 | fmt.Println("Logged in successfully!")
140 | return nil
141 | },
142 | }
143 |
144 | func cors(next http.Handler) http.Handler {
145 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
147 | w.Header().Set("Access-Control-Allow-Origin", "*")
148 | next.ServeHTTP(w, r)
149 | })
150 | }
151 | func startHTTPServer(inputChan chan string) {
152 | handleSubmit := func(res http.ResponseWriter, req *http.Request) {
153 | code, err := io.ReadAll(req.Body)
154 | if err != nil {
155 | return
156 | }
157 | inputChan <- string(code)
158 | // Clear current line
159 | fmt.Print("\n\033[1A\033[K")
160 | }
161 |
162 | handleHealth := func(res http.ResponseWriter, req *http.Request) {
163 | // 200 OK
164 | }
165 |
166 | http.Handle("POST /submit", cors(http.HandlerFunc(handleSubmit)))
167 | http.Handle("/health", cors(http.HandlerFunc(handleHealth)))
168 |
169 | // if we fail, oh well. we fall back to entering the code
170 | _ = http.ListenAndServe("localhost:9417", nil)
171 | }
172 |
173 | func init() {
174 | rootCmd.AddCommand(loginCmd)
175 | }
176 |
--------------------------------------------------------------------------------
/cmd/logout.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "net/http"
7 | "time"
8 |
9 | "github.com/spf13/cobra"
10 | "github.com/spf13/viper"
11 | )
12 |
13 | func logout() {
14 | api_url := viper.GetString("api_url")
15 | client := &http.Client{}
16 | // Best effort - logout should never fail
17 | r, _ := http.NewRequest("POST", api_url+"/v1/auth/logout", bytes.NewBuffer([]byte{}))
18 | r.Header.Add("X-Refresh-Token", viper.GetString("refresh_token"))
19 | client.Do(r)
20 |
21 | viper.Set("access_token", "")
22 | viper.Set("refresh_token", "")
23 | viper.Set("last_refresh", time.Now().Unix())
24 | viper.WriteConfig()
25 | fmt.Println("Logged out successfully.")
26 | }
27 |
28 | var logoutCmd = &cobra.Command{
29 | Use: "logout",
30 | Aliases: []string{"signout"},
31 | Short: "Disconnect the CLI from your account",
32 | PreRun: requireAuth,
33 | SilenceUsage: true,
34 | RunE: func(cmd *cobra.Command, args []string) error {
35 | logout()
36 | return nil
37 | },
38 | }
39 |
40 | func init() {
41 | rootCmd.AddCommand(logoutCmd)
42 | }
43 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 | "path"
8 | "time"
9 |
10 | api "github.com/bootdotdev/bootdev/client"
11 | "github.com/bootdotdev/bootdev/version"
12 | "github.com/spf13/cobra"
13 | "github.com/spf13/viper"
14 | )
15 |
16 | var cfgFile string
17 |
18 | var rootCmd = &cobra.Command{
19 | Use: "bootdev",
20 | Short: "The official boot.dev CLI",
21 | Long: `The official CLI for boot.dev. This program is meant
22 | to be a companion app (not a replacement) for the website.`,
23 | }
24 |
25 | // Execute adds all child commands to the root command and sets flags appropriately.
26 | // This is called by main.main(). It only needs to happen once to the rootCmd.
27 | func Execute(currentVersion string) error {
28 | rootCmd.Version = currentVersion
29 | info := version.FetchUpdateInfo(rootCmd.Version)
30 | defer info.PromptUpdateIfAvailable()
31 | ctx := version.WithContext(context.Background(), &info)
32 | return rootCmd.ExecuteContext(ctx)
33 | }
34 |
35 | func init() {
36 | cobra.OnInitialize(initConfig)
37 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.bootdev.yaml)")
38 | }
39 |
40 | func readViperConfig(paths []string) error {
41 | for _, path := range paths {
42 | _, err := os.Stat(path)
43 | if err == nil {
44 | viper.SetConfigFile(path)
45 | break
46 | }
47 | }
48 | return viper.ReadInConfig()
49 | }
50 |
51 | // initConfig reads in config file and ENV variables if set.
52 | func initConfig() {
53 | viper.SetDefault("frontend_url", "https://boot.dev")
54 | viper.SetDefault("api_url", "https://api.boot.dev")
55 | viper.SetDefault("access_token", "")
56 | viper.SetDefault("refresh_token", "")
57 | viper.SetDefault("last_refresh", 0)
58 | if cfgFile != "" {
59 | // Use config file from the flag.
60 | viper.SetConfigFile(cfgFile)
61 | err := viper.ReadInConfig()
62 | cobra.CheckErr(err)
63 | } else {
64 | // Find home directory.
65 | home, err := os.UserHomeDir()
66 | cobra.CheckErr(err)
67 |
68 | // viper's built in config path thing sucks, let's do it ourselves
69 | defaultPath := path.Join(home, ".bootdev.yaml")
70 | configPaths := []string{}
71 | configPaths = append(configPaths, path.Join(home, ".config", "bootdev", "config.yaml"))
72 | configPaths = append(configPaths, defaultPath)
73 | if err := readViperConfig(configPaths); err != nil {
74 | viper.SafeWriteConfigAs(defaultPath)
75 | viper.SetConfigFile(defaultPath)
76 | err = viper.ReadInConfig()
77 | cobra.CheckErr(err)
78 | }
79 | }
80 |
81 | viper.SetEnvPrefix("bd")
82 | viper.AutomaticEnv() // read in environment variables that match
83 | }
84 |
85 | // Chain multiple commands together.
86 | func compose(commands ...func(cmd *cobra.Command, args []string)) func(cmd *cobra.Command, args []string) {
87 | return func(cmd *cobra.Command, args []string) {
88 | for _, command := range commands {
89 | command(cmd, args)
90 | }
91 | }
92 | }
93 |
94 | // Call this function at the beginning of a command handler
95 | // if you want to require the user to update their CLI first.
96 | func requireUpdated(cmd *cobra.Command, args []string) {
97 | info := version.FromContext(cmd.Context())
98 | if info == nil {
99 | fmt.Fprintln(os.Stderr, "Failed to fetch update info. Are you online?")
100 | os.Exit(1)
101 | }
102 | if info.FailedToFetch != nil {
103 | fmt.Fprintf(os.Stderr, "Failed to fetch update info: %s\n", info.FailedToFetch.Error())
104 | os.Exit(1)
105 | }
106 | if info.IsUpdateRequired {
107 | info.PromptUpdateIfAvailable()
108 | os.Exit(1)
109 | }
110 | }
111 |
112 | // Call this function at the beginning of a command handler
113 | // if you need to make authenticated requests. This will
114 | // automatically refresh the tokens, if necessary, and prompt
115 | // the user to re-login if anything goes wrong.
116 | func requireAuth(cmd *cobra.Command, args []string) {
117 | promptLoginAndExitIf := func(condition bool) {
118 | if condition {
119 | fmt.Fprintln(os.Stderr, "You must be logged in to use that command.")
120 | fmt.Fprintln(os.Stderr, "Please run 'bootdev login' first.")
121 | os.Exit(1)
122 | }
123 | }
124 |
125 | access_token := viper.GetString("access_token")
126 | promptLoginAndExitIf(access_token == "")
127 |
128 | // We only refresh if our token is getting stale.
129 | last_refresh := viper.GetInt64("last_refresh")
130 | if time.Now().Add(-time.Minute*55).Unix() <= last_refresh {
131 | return
132 | }
133 |
134 | creds, err := api.FetchAccessToken()
135 | promptLoginAndExitIf(err != nil)
136 | if creds.AccessToken == "" || creds.RefreshToken == "" {
137 | promptLoginAndExitIf(err != nil)
138 | }
139 |
140 | viper.Set("access_token", creds.AccessToken)
141 | viper.Set("refresh_token", creds.RefreshToken)
142 | viper.Set("last_refresh", time.Now().Unix())
143 |
144 | err = viper.WriteConfig()
145 | promptLoginAndExitIf(err != nil)
146 | }
147 |
--------------------------------------------------------------------------------
/cmd/run.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "github.com/spf13/cobra"
5 | )
6 |
7 | func init() {
8 | rootCmd.AddCommand(runCmd)
9 | runCmd.Flags().BoolVarP(&forceSubmit, "submit", "s", false, "shortcut flag to submit instead of run")
10 | }
11 |
12 | // runCmd represents the run command
13 | var runCmd = &cobra.Command{
14 | Use: "run UUID",
15 | Args: cobra.MatchAll(cobra.RangeArgs(1, 10)),
16 | Short: "Run a lesson without submitting",
17 | PreRun: compose(requireUpdated, requireAuth),
18 | RunE: submissionHandler,
19 | }
20 |
--------------------------------------------------------------------------------
/cmd/submit.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 |
7 | "github.com/bootdotdev/bootdev/checks"
8 | api "github.com/bootdotdev/bootdev/client"
9 | "github.com/bootdotdev/bootdev/render"
10 | "github.com/spf13/cobra"
11 | "github.com/spf13/viper"
12 | )
13 |
14 | var forceSubmit bool
15 |
16 | func init() {
17 | rootCmd.AddCommand(submitCmd)
18 | }
19 |
20 | // submitCmd represents the submit command
21 | var submitCmd = &cobra.Command{
22 | Use: "submit UUID",
23 | Args: cobra.MatchAll(cobra.RangeArgs(1, 10)),
24 | Short: "Submit a lesson",
25 | PreRun: compose(requireUpdated, requireAuth),
26 | RunE: submissionHandler,
27 | }
28 |
29 | func submissionHandler(cmd *cobra.Command, args []string) error {
30 | cmd.SilenceUsage = true
31 | isSubmit := cmd.Name() == "submit" || forceSubmit
32 | lessonUUID := args[0]
33 |
34 | lesson, err := api.FetchLesson(lessonUUID)
35 | if err != nil {
36 | return err
37 | }
38 | if lesson.Lesson.Type != "type_cli" {
39 | return errors.New("unable to run lesson: unsupported lesson type")
40 | }
41 | if lesson.Lesson.LessonDataCLI == nil {
42 | return errors.New("unable to run lesson: missing lesson data")
43 | }
44 |
45 | data := lesson.Lesson.LessonDataCLI.CLIData
46 | overrideBaseURL := viper.GetString("override_base_url")
47 | if overrideBaseURL != "" {
48 | fmt.Printf("Using overridden base_url: %v\n", overrideBaseURL)
49 | fmt.Printf("You can reset to the default with `bootdev config base_url --reset`\n\n")
50 | }
51 |
52 | results := checks.CLIChecks(data, overrideBaseURL)
53 | if isSubmit {
54 | failure, err := api.SubmitCLILesson(lessonUUID, results)
55 | if err != nil {
56 | return err
57 | }
58 | render.RenderSubmission(data, results, failure)
59 | } else {
60 | render.RenderRun(data, results)
61 | }
62 | return nil
63 | }
64 |
--------------------------------------------------------------------------------
/cmd/upgrade.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "os/exec"
7 | "regexp"
8 |
9 | "github.com/bootdotdev/bootdev/version"
10 | "github.com/spf13/cobra"
11 | )
12 |
13 | var upgradeCmd = &cobra.Command{
14 | Use: "upgrade",
15 | Aliases: []string{"update"},
16 | Short: "Installs the latest version of the CLI.",
17 | Run: func(cmd *cobra.Command, args []string) {
18 | info := version.FromContext(cmd.Context())
19 | if !info.IsOutdated {
20 | fmt.Println("Boot.dev CLI is already up to date.")
21 | return
22 | }
23 | // install the latest version
24 | command := exec.Command("go", "install", "github.com/bootdotdev/bootdev@latest")
25 | _, err := command.Output()
26 | cobra.CheckErr(err)
27 |
28 | // Get the new version info
29 | command = exec.Command("bootdev", "--version")
30 | b, err := command.Output()
31 | cobra.CheckErr(err)
32 | re := regexp.MustCompile(`v\d+\.\d+\.\d+`)
33 | version := re.FindString(string(b))
34 | fmt.Printf("Successfully upgraded to %s!\n", version)
35 | os.Exit(0)
36 | },
37 | }
38 |
39 | func init() {
40 | rootCmd.AddCommand(upgradeCmd)
41 | }
42 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/bootdotdev/bootdev
2 |
3 | go 1.22.1
4 |
5 | require (
6 | github.com/itchyny/gojq v0.12.15
7 | github.com/spf13/cobra v1.8.0
8 | github.com/spf13/viper v1.18.2
9 | golang.org/x/mod v0.17.0
10 | )
11 |
12 | require (
13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
14 | github.com/charmbracelet/bubbles v0.18.0 // indirect
15 | github.com/charmbracelet/bubbletea v0.26.1 // indirect
16 | github.com/charmbracelet/lipgloss v0.10.0 // indirect
17 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
18 | github.com/fsnotify/fsnotify v1.7.0 // indirect
19 | github.com/hashicorp/hcl v1.0.0 // indirect
20 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
21 | github.com/itchyny/timefmt-go v0.1.5 // indirect
22 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
23 | github.com/magiconair/properties v1.8.7 // indirect
24 | github.com/mattn/go-isatty v0.0.20 // indirect
25 | github.com/mattn/go-localereader v0.0.1 // indirect
26 | github.com/mattn/go-runewidth v0.0.15 // indirect
27 | github.com/mitchellh/mapstructure v1.5.0 // indirect
28 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
29 | github.com/muesli/cancelreader v0.2.2 // indirect
30 | github.com/muesli/reflow v0.3.0 // indirect
31 | github.com/muesli/termenv v0.15.2 // indirect
32 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect
33 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
34 | github.com/rivo/uniseg v0.4.7 // indirect
35 | github.com/sagikazarmark/locafero v0.4.0 // indirect
36 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect
37 | github.com/sourcegraph/conc v0.3.0 // indirect
38 | github.com/spf13/afero v1.11.0 // indirect
39 | github.com/spf13/cast v1.6.0 // indirect
40 | github.com/spf13/pflag v1.0.5 // indirect
41 | github.com/subosito/gotenv v1.6.0 // indirect
42 | go.uber.org/atomic v1.9.0 // indirect
43 | go.uber.org/multierr v1.9.0 // indirect
44 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
45 | golang.org/x/sync v0.7.0 // indirect
46 | golang.org/x/sys v0.19.0 // indirect
47 | golang.org/x/term v0.19.0 // indirect
48 | golang.org/x/text v0.14.0 // indirect
49 | gopkg.in/ini.v1 v1.67.0 // indirect
50 | gopkg.in/yaml.v3 v3.0.1 // indirect
51 | )
52 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
3 | github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
4 | github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
5 | github.com/charmbracelet/bubbletea v0.26.1 h1:xujcQeF73rh4jwu3+zhfQsvV18x+7zIjlw7/CYbzGJ0=
6 | github.com/charmbracelet/bubbletea v0.26.1/go.mod h1:FzKr7sKoO8iFVcdIBM9J0sJOcQv5nDQaYwsee3kpbgo=
7 | github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s=
8 | github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
9 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
14 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
15 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
16 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
17 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
18 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
19 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
20 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
21 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
22 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
23 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
24 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
25 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
26 | github.com/itchyny/gojq v0.12.15 h1:WC1Nxbx4Ifw5U2oQWACYz32JK8G9qxNtHzrvW4KEcqI=
27 | github.com/itchyny/gojq v0.12.15/go.mod h1:uWAHCbCIla1jiNxmeT5/B5mOjSdfkCq6p8vxWg+BM10=
28 | github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
29 | github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
30 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
31 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
32 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
33 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
34 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
35 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
36 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
37 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
38 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
39 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
40 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
41 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
42 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
43 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
44 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
45 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
46 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
47 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
48 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
49 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
50 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
51 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
52 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
53 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
54 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
55 | github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
56 | github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
57 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
58 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
59 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
60 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
61 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
62 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
63 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
64 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
65 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
66 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
67 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
68 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
69 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
70 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
71 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
72 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
73 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
74 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
75 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
76 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
77 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
78 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
79 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
80 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
81 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
82 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
83 | github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
84 | github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
85 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
86 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
87 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
88 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
89 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
90 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
91 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
92 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
93 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
94 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
95 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
96 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
97 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
98 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
99 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
100 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
101 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
102 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
103 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
104 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
105 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
106 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
107 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
108 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
109 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
110 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
111 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
112 | golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
113 | golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
114 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
115 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
116 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
117 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
118 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
119 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
120 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
121 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
122 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
123 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
124 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | //go:build darwin || linux
2 |
3 | package main
4 |
5 | import (
6 | "os"
7 | "strings"
8 |
9 | _ "embed"
10 |
11 | "github.com/bootdotdev/bootdev/cmd"
12 | )
13 |
14 | //go:embed version.txt
15 | var version string
16 |
17 | func main() {
18 | err := cmd.Execute(strings.Trim(version, "\n"))
19 | if err != nil {
20 | os.Exit(1)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/main_windows.go:
--------------------------------------------------------------------------------
1 | //go:build windows
2 |
3 | package main
4 |
5 | func main() {
6 | windows_is_not_supported_please_use_WSL()
7 | }
8 |
--------------------------------------------------------------------------------
/render/render.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "os"
8 | "strings"
9 | "sync"
10 |
11 | "github.com/bootdotdev/bootdev/checks"
12 | api "github.com/bootdotdev/bootdev/client"
13 | "github.com/charmbracelet/bubbles/spinner"
14 | tea "github.com/charmbracelet/bubbletea"
15 | "github.com/charmbracelet/lipgloss"
16 | "github.com/muesli/termenv"
17 | "github.com/spf13/cobra"
18 | "github.com/spf13/viper"
19 | )
20 |
21 | var green lipgloss.Style
22 | var red lipgloss.Style
23 | var gray lipgloss.Style
24 | var borderBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder())
25 |
26 | type testModel struct {
27 | text string
28 | passed *bool
29 | finished bool
30 | }
31 |
32 | type startTestMsg struct {
33 | text string
34 | }
35 |
36 | type resolveTestMsg struct {
37 | index int
38 | passed *bool
39 | }
40 |
41 | func renderTestHeader(header string, spinner spinner.Model, isFinished bool, isSubmit bool, passed *bool) string {
42 | cmdStr := renderTest(header, spinner.View(), isFinished, &isSubmit, passed)
43 | box := borderBox.Render(fmt.Sprintf(" %s ", cmdStr))
44 | sliced := strings.Split(box, "\n")
45 | sliced[2] = strings.Replace(sliced[2], "─", "┬", 1)
46 | return strings.Join(sliced, "\n") + "\n"
47 | }
48 |
49 | func renderTestResponseVars(respVars []api.HTTPRequestResponseVariable) string {
50 | var str string
51 | for _, respVar := range respVars {
52 | varStr := gray.Render(fmt.Sprintf(" * Saving `%s` from `%s`", respVar.Name, respVar.Path))
53 | edges := " ├─"
54 | for range lipgloss.Height(varStr) - 1 {
55 | edges += "\n │ "
56 | }
57 | str += lipgloss.JoinHorizontal(lipgloss.Top, edges, varStr)
58 | str += "\n"
59 | }
60 | return str
61 | }
62 |
63 | func renderTests(tests []testModel, spinner string) string {
64 | var str string
65 | for _, test := range tests {
66 | testStr := renderTest(test.text, spinner, test.finished, nil, test.passed)
67 | testStr = fmt.Sprintf(" %s", testStr)
68 |
69 | edges := " ├─"
70 | for range lipgloss.Height(testStr) - 1 {
71 | edges += "\n │ "
72 | }
73 | str += lipgloss.JoinHorizontal(lipgloss.Top, edges, testStr)
74 | str += "\n"
75 | }
76 | return str
77 | }
78 |
79 | func renderTest(text string, spinner string, isFinished bool, isSubmit *bool, passed *bool) string {
80 | testStr := ""
81 | if !isFinished {
82 | testStr += fmt.Sprintf("%s %s", spinner, text)
83 | } else if isSubmit != nil && !*isSubmit {
84 | testStr += text
85 | } else if passed == nil {
86 | testStr += gray.Render(fmt.Sprintf("? %s", text))
87 | } else if *passed {
88 | testStr += green.Render(fmt.Sprintf("✓ %s", text))
89 | } else {
90 | testStr += red.Render(fmt.Sprintf("X %s", text))
91 | }
92 | return testStr
93 | }
94 |
95 | type doneStepMsg struct {
96 | failure *api.StructuredErrCLI
97 | }
98 |
99 | type startStepMsg struct {
100 | responseVariables []api.HTTPRequestResponseVariable
101 | cmd string
102 | url string
103 | method string
104 | }
105 |
106 | type resolveStepMsg struct {
107 | index int
108 | passed *bool
109 | result *api.CLIStepResult
110 | }
111 |
112 | type stepModel struct {
113 | responseVariables []api.HTTPRequestResponseVariable
114 | step string
115 | passed *bool
116 | result *api.CLIStepResult
117 | finished bool
118 | tests []testModel
119 | }
120 |
121 | type rootModel struct {
122 | steps []stepModel
123 | spinner spinner.Model
124 | failure *api.StructuredErrCLI
125 | isSubmit bool
126 | success bool
127 | finalized bool
128 | clear bool
129 | }
130 |
131 | func initModel(isSubmit bool) rootModel {
132 | s := spinner.New()
133 | s.Spinner = spinner.Dot
134 | return rootModel{
135 | spinner: s,
136 | isSubmit: isSubmit,
137 | steps: []stepModel{},
138 | }
139 | }
140 |
141 | func (m rootModel) Init() tea.Cmd {
142 | green = lipgloss.NewStyle().Foreground(lipgloss.Color(viper.GetString("color.green")))
143 | red = lipgloss.NewStyle().Foreground(lipgloss.Color(viper.GetString("color.red")))
144 | gray = lipgloss.NewStyle().Foreground(lipgloss.Color(viper.GetString("color.gray")))
145 | return m.spinner.Tick
146 | }
147 |
148 | func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
149 | switch msg := msg.(type) {
150 | case doneStepMsg:
151 | m.failure = msg.failure
152 | if m.failure == nil && m.isSubmit {
153 | m.success = true
154 | }
155 | m.clear = true
156 | return m, tea.Quit
157 |
158 | case startStepMsg:
159 | step := fmt.Sprintf("Running: %s", msg.cmd)
160 | if msg.cmd == "" {
161 | step = fmt.Sprintf("%s %s", msg.method, msg.url)
162 | }
163 | m.steps = append(m.steps, stepModel{
164 | step: step,
165 | tests: []testModel{},
166 | responseVariables: msg.responseVariables,
167 | })
168 | return m, nil
169 |
170 | case resolveStepMsg:
171 | m.steps[msg.index].passed = msg.passed
172 | m.steps[msg.index].finished = true
173 | m.steps[msg.index].result = msg.result
174 | return m, nil
175 |
176 | case startTestMsg:
177 | m.steps[len(m.steps)-1].tests = append(
178 | m.steps[len(m.steps)-1].tests,
179 | testModel{text: msg.text},
180 | )
181 | return m, nil
182 |
183 | case resolveTestMsg:
184 | m.steps[len(m.steps)-1].tests[msg.index].passed = msg.passed
185 | m.steps[len(m.steps)-1].tests[msg.index].finished = true
186 | return m, nil
187 |
188 | default:
189 | var cmd tea.Cmd
190 | m.spinner, cmd = m.spinner.Update(msg)
191 | return m, cmd
192 | }
193 | }
194 |
195 | func (m rootModel) View() string {
196 | if m.clear {
197 | return ""
198 | }
199 | s := m.spinner.View()
200 | var str string
201 | for _, step := range m.steps {
202 | str += renderTestHeader(step.step, m.spinner, step.finished, m.isSubmit, step.passed)
203 | str += renderTests(step.tests, s)
204 | str += renderTestResponseVars(step.responseVariables)
205 | if step.result == nil || !m.finalized {
206 | continue
207 | }
208 |
209 | if step.result.CLICommandResult != nil {
210 | // render the results
211 | for _, test := range step.tests {
212 | // for clarity, only show exit code if it's tested
213 | if strings.Contains(test.text, "exit code") {
214 | str += fmt.Sprintf("\n > Command exit code: %d\n", step.result.CLICommandResult.ExitCode)
215 | break
216 | }
217 | }
218 | str += " > Command stdout:\n\n"
219 | sliced := strings.Split(step.result.CLICommandResult.Stdout, "\n")
220 | for _, s := range sliced {
221 | str += gray.Render(s) + "\n"
222 | }
223 |
224 | }
225 |
226 | if step.result.HTTPRequestResult != nil {
227 | str += printHTTPRequestResult(*step.result.HTTPRequestResult)
228 | }
229 | }
230 | if m.failure != nil {
231 | str += red.Render("\n\nError: "+m.failure.ErrorMessage) + "\n\n"
232 | } else if m.success {
233 | str += "\n\n" + green.Render("All tests passed! 🎉") + "\n\n"
234 | str += green.Render("Return to your browser to continue with the next lesson.") + "\n\n"
235 | }
236 | return str
237 | }
238 |
239 | func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string) string {
240 | if test.ExitCode != nil {
241 | return fmt.Sprintf("Expect exit code %d", *test.ExitCode)
242 | }
243 | if test.StdoutLinesGt != nil {
244 | return fmt.Sprintf("Expect > %d lines on stdout", *test.StdoutLinesGt)
245 | }
246 | if test.StdoutContainsAll != nil {
247 | str := "Expect stdout to contain all of:"
248 | for _, contains := range test.StdoutContainsAll {
249 | interpolatedContains := checks.InterpolateVariables(contains, variables)
250 | str += fmt.Sprintf("\n - '%s'", interpolatedContains)
251 | }
252 | return str
253 | }
254 | if test.StdoutContainsNone != nil {
255 | str := "Expect stdout to contain none of:"
256 | for _, containsNone := range test.StdoutContainsNone {
257 | interpolatedContainsNone := checks.InterpolateVariables(containsNone, variables)
258 | str += fmt.Sprintf("\n - '%s'", interpolatedContainsNone)
259 | }
260 | return str
261 | }
262 | return ""
263 | }
264 |
265 | func pointerToBool(a bool) *bool {
266 | return &a
267 | }
268 |
269 | func printHTTPRequestResult(result api.HTTPRequestResult) string {
270 | if result.Err != "" {
271 | return fmt.Sprintf(" Err: %v\n\n", result.Err)
272 | }
273 |
274 | str := ""
275 |
276 | str += fmt.Sprintf(" Response Status Code: %v\n", result.StatusCode)
277 |
278 | filteredHeaders := make(map[string]string)
279 | for respK, respV := range result.ResponseHeaders {
280 | for _, test := range result.Request.Tests {
281 | if test.HeadersContain == nil {
282 | continue
283 | }
284 | interpolatedTestHeaderKey := checks.InterpolateVariables(test.HeadersContain.Key, result.Variables)
285 | if strings.EqualFold(respK, interpolatedTestHeaderKey) {
286 | filteredHeaders[respK] = respV
287 | }
288 | }
289 | }
290 |
291 | filteredTrailers := make(map[string]string)
292 | for respK, respV := range result.ResponseTrailers {
293 | for _, test := range result.Request.Tests {
294 | if test.TrailersContain == nil {
295 | continue
296 | }
297 |
298 | interpolatedTestTrailerKey := checks.InterpolateVariables(test.TrailersContain.Key, result.Variables)
299 | if strings.EqualFold(respK, interpolatedTestTrailerKey) {
300 | filteredTrailers[respK] = respV
301 | }
302 | }
303 | }
304 |
305 | if len(filteredHeaders) > 0 {
306 | str += " Response Headers: \n"
307 | for k, v := range filteredHeaders {
308 | str += fmt.Sprintf(" - %v: %v\n", k, v)
309 | }
310 | }
311 |
312 | str += " Response Body: \n"
313 | bytes := []byte(result.BodyString)
314 | contentType := http.DetectContentType(bytes)
315 | if contentType == "application/json" || strings.HasPrefix(contentType, "text/") {
316 | var unmarshalled any
317 | err := json.Unmarshal([]byte(result.BodyString), &unmarshalled)
318 | if err == nil {
319 | pretty, err := json.MarshalIndent(unmarshalled, "", " ")
320 | if err == nil {
321 | str += string(pretty)
322 | } else {
323 | str += result.BodyString
324 | }
325 | } else {
326 | str += result.BodyString
327 | }
328 | } else {
329 | str += fmt.Sprintf("Binary %s file", contentType)
330 | }
331 | str += "\n"
332 |
333 | if len(filteredTrailers) > 0 {
334 | str += " Response Trailers: \n"
335 | for k, v := range filteredTrailers {
336 | str += fmt.Sprintf(" - %v: %v\n", k, v)
337 | }
338 | }
339 |
340 | if len(result.Variables) > 0 {
341 | str += " Variables available: \n"
342 | for k, v := range result.Variables {
343 | if v != "" {
344 | str += fmt.Sprintf(" - %v: %v\n", k, v)
345 | } else {
346 | str += fmt.Sprintf(" - %v: [not found]\n", k)
347 | }
348 | }
349 | }
350 | str += "\n"
351 |
352 | return str
353 | }
354 |
355 | func RenderRun(
356 | data api.CLIData,
357 | results []api.CLIStepResult,
358 | ) {
359 | renderer(data, results, nil, false)
360 | }
361 |
362 | func RenderSubmission(
363 | data api.CLIData,
364 | results []api.CLIStepResult,
365 | failure *api.StructuredErrCLI,
366 | ) {
367 | renderer(data, results, failure, true)
368 | }
369 |
370 | func renderer(
371 | data api.CLIData,
372 | results []api.CLIStepResult,
373 | failure *api.StructuredErrCLI,
374 | isSubmit bool,
375 | ) {
376 | var wg sync.WaitGroup
377 | ch := make(chan tea.Msg, 1)
378 | p := tea.NewProgram(initModel(isSubmit), tea.WithoutSignalHandler())
379 | wg.Add(1)
380 | go func() {
381 | defer wg.Done()
382 | if model, err := p.Run(); err != nil {
383 | fmt.Fprintln(os.Stderr, err)
384 | } else if r, ok := model.(rootModel); ok {
385 | r.clear = false
386 | r.finalized = true
387 | output := termenv.NewOutput(os.Stdout)
388 | output.WriteString(r.View())
389 | }
390 | }()
391 | go func() {
392 | for {
393 | msg := <-ch
394 | p.Send(msg)
395 | }
396 | }()
397 | wg.Add(1)
398 | go func() {
399 | defer wg.Done()
400 |
401 | for i, step := range data.Steps {
402 | switch {
403 | case step.CLICommand != nil && results[i].CLICommandResult != nil:
404 | renderCLICommand(*step.CLICommand, *results[i].CLICommandResult, failure, isSubmit, ch, i)
405 | case step.HTTPRequest != nil && results[i].HTTPRequestResult != nil:
406 | renderHTTPRequest(*step.HTTPRequest, *results[i].HTTPRequestResult, failure, isSubmit, data.BaseURLDefault, ch, i)
407 | default:
408 | cobra.CheckErr("unable to run lesson: missing results")
409 | }
410 | }
411 |
412 | ch <- doneStepMsg{failure: failure}
413 | }()
414 | wg.Wait()
415 | }
416 |
417 | func renderCLICommand(
418 | cmd api.CLIStepCLICommand,
419 | result api.CLICommandResult,
420 | failure *api.StructuredErrCLI,
421 | isSubmit bool,
422 | ch chan tea.Msg,
423 | index int,
424 | ) {
425 | ch <- startStepMsg{cmd: result.FinalCommand}
426 |
427 | for _, test := range cmd.Tests {
428 | ch <- startTestMsg{text: prettyPrintCLICommand(test, result.Variables)}
429 | }
430 |
431 | earlierCmdFailed := false
432 | if failure != nil {
433 | earlierCmdFailed = failure.FailedStepIndex < index
434 | }
435 | for j := range cmd.Tests {
436 | earlierTestFailed := false
437 | if failure != nil {
438 | if earlierCmdFailed {
439 | earlierTestFailed = true
440 | } else if failure.FailedStepIndex == index {
441 | earlierTestFailed = failure.FailedTestIndex < j
442 | }
443 | }
444 | if !isSubmit {
445 | ch <- resolveTestMsg{index: j}
446 | } else if earlierTestFailed {
447 | ch <- resolveTestMsg{index: j}
448 | } else {
449 | passed := failure == nil || failure.FailedStepIndex != index || failure.FailedTestIndex != j
450 | ch <- resolveTestMsg{
451 | index: j,
452 | passed: pointerToBool(passed),
453 | }
454 | }
455 | }
456 |
457 | if !isSubmit {
458 | ch <- resolveStepMsg{
459 | index: index,
460 | result: &api.CLIStepResult{
461 | CLICommandResult: &result,
462 | },
463 | }
464 | } else if earlierCmdFailed {
465 | ch <- resolveStepMsg{index: index}
466 | } else {
467 | passed := failure == nil || failure.FailedStepIndex != index
468 | if passed {
469 | ch <- resolveStepMsg{
470 | index: index,
471 | passed: pointerToBool(passed),
472 | }
473 | } else {
474 | ch <- resolveStepMsg{
475 | index: index,
476 | passed: pointerToBool(passed),
477 | result: &api.CLIStepResult{
478 | CLICommandResult: &result,
479 | },
480 | }
481 | }
482 | }
483 | }
484 |
485 | func renderHTTPRequest(
486 | req api.CLIStepHTTPRequest,
487 | result api.HTTPRequestResult,
488 | failure *api.StructuredErrCLI,
489 | isSubmit bool,
490 | baseURLDefault string,
491 | ch chan tea.Msg,
492 | index int,
493 | ) {
494 |
495 | baseURL := viper.GetString("override_base_url")
496 | if baseURL == "" {
497 | baseURL = baseURLDefault
498 | }
499 | fullURL := strings.Replace(req.Request.FullURL, api.BaseURLPlaceholder, baseURL, 1)
500 |
501 | ch <- startStepMsg{
502 | url: checks.InterpolateVariables(fullURL, result.Variables),
503 | method: req.Request.Method,
504 | responseVariables: req.ResponseVariables,
505 | }
506 |
507 | for _, test := range req.Tests {
508 | ch <- startTestMsg{text: prettyPrintHTTPTest(test, result.Variables)}
509 | }
510 |
511 | for j := range req.Tests {
512 | if !isSubmit {
513 | ch <- resolveTestMsg{index: j}
514 | } else if failure != nil && (failure.FailedStepIndex < index || (failure.FailedStepIndex == index && failure.FailedTestIndex < j)) {
515 | ch <- resolveTestMsg{index: j}
516 | } else {
517 | ch <- resolveTestMsg{index: j, passed: pointerToBool(failure == nil || !(failure.FailedStepIndex == index && failure.FailedTestIndex == j))}
518 | }
519 | }
520 |
521 | if !isSubmit {
522 | ch <- resolveStepMsg{
523 | index: index,
524 | result: &api.CLIStepResult{
525 | HTTPRequestResult: &result,
526 | },
527 | }
528 | } else if failure != nil && failure.FailedStepIndex < index {
529 | ch <- resolveStepMsg{index: index}
530 | } else {
531 | passed := failure == nil || failure.FailedStepIndex != index
532 | if passed {
533 | ch <- resolveStepMsg{
534 | index: index,
535 | passed: pointerToBool(passed),
536 | }
537 | } else {
538 | ch <- resolveStepMsg{
539 | index: index,
540 | passed: pointerToBool(passed),
541 | result: &api.CLIStepResult{
542 | HTTPRequestResult: &result,
543 | },
544 | }
545 | }
546 | }
547 | }
548 |
549 | func prettyPrintHTTPTest(test api.HTTPRequestTest, variables map[string]string) string {
550 | if test.StatusCode != nil {
551 | return fmt.Sprintf("Expecting status code: %d", *test.StatusCode)
552 | }
553 | if test.BodyContains != nil {
554 | interpolated := checks.InterpolateVariables(*test.BodyContains, variables)
555 | return fmt.Sprintf("Expecting body to contain: %s", interpolated)
556 | }
557 | if test.BodyContainsNone != nil {
558 | interpolated := checks.InterpolateVariables(*test.BodyContainsNone, variables)
559 | return fmt.Sprintf("Expecting JSON body to not contain: %s", interpolated)
560 | }
561 | if test.HeadersContain != nil {
562 | interpolatedKey := checks.InterpolateVariables(test.HeadersContain.Key, variables)
563 | interpolatedValue := checks.InterpolateVariables(test.HeadersContain.Value, variables)
564 | return fmt.Sprintf("Expecting headers to contain: '%s: %v'", interpolatedKey, interpolatedValue)
565 | }
566 | if test.TrailersContain != nil {
567 | interpolatedKey := checks.InterpolateVariables(test.TrailersContain.Key, variables)
568 | interpolatedValue := checks.InterpolateVariables(test.TrailersContain.Value, variables)
569 | return fmt.Sprintf("Expecting trailers to contain: '%s: %v'", interpolatedKey, interpolatedValue)
570 | }
571 | if test.JSONValue != nil {
572 | var val any
573 | var op any
574 | if test.JSONValue.IntValue != nil {
575 | val = *test.JSONValue.IntValue
576 | } else if test.JSONValue.StringValue != nil {
577 | val = *test.JSONValue.StringValue
578 | } else if test.JSONValue.BoolValue != nil {
579 | val = *test.JSONValue.BoolValue
580 | }
581 | if test.JSONValue.Operator == api.OpEquals {
582 | op = "to be equal to"
583 | } else if test.JSONValue.Operator == api.OpGreaterThan {
584 | op = "to be greater than"
585 | } else if test.JSONValue.Operator == api.OpContains {
586 | op = "contains"
587 | } else if test.JSONValue.Operator == api.OpNotContains {
588 | op = "to not contain"
589 | }
590 | expecting := fmt.Sprintf("Expecting JSON at %v %s %v", test.JSONValue.Path, op, val)
591 | return checks.InterpolateVariables(expecting, variables)
592 | }
593 | return ""
594 | }
595 |
--------------------------------------------------------------------------------
/version.txt:
--------------------------------------------------------------------------------
1 | v1.19.1
2 |
--------------------------------------------------------------------------------
/version/context.go:
--------------------------------------------------------------------------------
1 | package version
2 |
3 | import "context"
4 |
5 | var ContextKey = struct{ string }{"version"}
6 |
7 | func WithContext(ctx context.Context, version *VersionInfo) context.Context {
8 | return context.WithValue(ctx, ContextKey, version)
9 | }
10 |
11 | func FromContext(ctx context.Context) *VersionInfo {
12 | if c, ok := ctx.Value(ContextKey).(*VersionInfo); ok {
13 | return c
14 | }
15 |
16 | return nil
17 | }
18 |
--------------------------------------------------------------------------------
/version/version.go:
--------------------------------------------------------------------------------
1 | package version
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "os"
9 | "os/exec"
10 | "slices"
11 | "strings"
12 |
13 | "golang.org/x/mod/semver"
14 | )
15 |
16 | const repoOwner = "bootdotdev"
17 | const repoName = "bootdev"
18 |
19 | type VersionInfo struct {
20 | CurrentVersion string
21 | LatestVersion string
22 | IsOutdated bool
23 | IsUpdateRequired bool
24 | FailedToFetch error
25 | }
26 |
27 | func FetchUpdateInfo(currentVersion string) VersionInfo {
28 | latest, err := getLatestVersion()
29 | if err != nil {
30 | return VersionInfo{
31 | FailedToFetch: err,
32 | }
33 | }
34 | isUpdateRequired := isUpdateRequired(currentVersion, latest)
35 | isOutdated := isOutdated(currentVersion, latest)
36 | return VersionInfo{
37 | IsUpdateRequired: isUpdateRequired,
38 | IsOutdated: isOutdated,
39 | CurrentVersion: currentVersion,
40 | LatestVersion: latest,
41 | }
42 | }
43 |
44 | func (v *VersionInfo) PromptUpdateIfAvailable() {
45 | if v.IsOutdated {
46 | fmt.Fprintln(os.Stderr, "A new version of the bootdev CLI is available!")
47 | fmt.Fprintln(os.Stderr, "Please run the following command to update:")
48 | fmt.Fprintln(os.Stderr, " bootdev upgrade")
49 | fmt.Fprintln(os.Stderr, "or")
50 | fmt.Fprintf(os.Stderr, " go install github.com/bootdotdev/bootdev@%s\n\n", v.LatestVersion)
51 | }
52 | }
53 |
54 | // Returns true if the current version is older than the latest.
55 | func isOutdated(current string, latest string) bool {
56 | return semver.Compare(current, latest) < 0
57 | }
58 |
59 | // Returns true if the latest version has a higher major or minor
60 | // number than the current version. If you don't want to force
61 | // an update, you can increment the patch number instead.
62 | func isUpdateRequired(current string, latest string) bool {
63 | latestMajorMinor := semver.MajorMinor(latest)
64 | currentMajorMinor := semver.MajorMinor(current)
65 | return semver.Compare(currentMajorMinor, latestMajorMinor) < 0
66 | }
67 |
68 | func getLatestVersion() (string, error) {
69 | goproxyDefault := "https://proxy.golang.org"
70 | goproxy := goproxyDefault
71 | cmd := exec.Command("go", "env", "GOPROXY")
72 | output, err := cmd.Output()
73 | if err == nil {
74 | goproxy = strings.TrimSpace(string(output))
75 | }
76 |
77 | proxies := strings.Split(goproxy, ",")
78 | if !slices.Contains(proxies, goproxyDefault) {
79 | proxies = append(proxies, goproxyDefault)
80 | }
81 |
82 | for _, proxy := range proxies {
83 | proxy = strings.TrimSpace(proxy)
84 | proxy = strings.TrimRight(proxy, "/")
85 | if proxy == "direct" || proxy == "off" {
86 | continue
87 | }
88 |
89 | url := fmt.Sprintf("%s/github.com/%s/%s/@latest", proxy, repoOwner, repoName)
90 | resp, err := http.Get(url)
91 | if err != nil {
92 | continue
93 | }
94 | defer resp.Body.Close()
95 |
96 | body, err := io.ReadAll(resp.Body)
97 | if err != nil {
98 | continue
99 | }
100 |
101 | var version struct{ Version string }
102 | if err = json.Unmarshal(body, &version); err != nil {
103 | continue
104 | }
105 |
106 | return version.Version, nil
107 | }
108 |
109 | return "", fmt.Errorf("failed to fetch latest version")
110 | }
111 |
--------------------------------------------------------------------------------