├── .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 | --------------------------------------------------------------------------------