├── .github
└── workflows
│ └── main.yml
├── LICENSE
├── README.md
├── bootdev
├── 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
├── messages
└── messages.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 |
--------------------------------------------------------------------------------
/bootdev:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bootdotdev/bootdev/4e8e601b061f9d7c9d54bd66679e8522534d77ed/bootdev
--------------------------------------------------------------------------------
/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 | "runtime"
14 | "strings"
15 | "time"
16 |
17 | api "github.com/bootdotdev/bootdev/client"
18 | "github.com/bootdotdev/bootdev/messages"
19 | tea "github.com/charmbracelet/bubbletea"
20 | "github.com/itchyny/gojq"
21 | "github.com/spf13/cobra"
22 | "github.com/spf13/viper"
23 | )
24 |
25 | func runCLICommand(command api.CLIStepCLICommand, variables map[string]string) (result api.CLICommandResult) {
26 | finalCommand := InterpolateVariables(command.Command, variables)
27 | result.FinalCommand = finalCommand
28 |
29 | var cmd *exec.Cmd
30 |
31 | if runtime.GOOS == "windows" {
32 | cmd = exec.Command("powershell", "-Command", finalCommand)
33 | } else {
34 | cmd = exec.Command("sh", "-c", finalCommand)
35 | }
36 |
37 | cmd.Env = append(os.Environ(), "LANG=en_US.UTF-8")
38 | b, err := cmd.CombinedOutput()
39 | if ee, ok := err.(*exec.ExitError); ok {
40 | result.ExitCode = ee.ExitCode()
41 | } else if err != nil {
42 | result.ExitCode = -2
43 | }
44 | result.Stdout = strings.TrimRight(string(b), " \n\t\r")
45 | result.Variables = variables
46 | return result
47 | }
48 |
49 | func runHTTPRequest(
50 | client *http.Client,
51 | baseURL string,
52 | variables map[string]string,
53 | requestStep api.CLIStepHTTPRequest,
54 | ) (
55 | result api.HTTPRequestResult,
56 | ) {
57 | finalBaseURL := strings.TrimSuffix(baseURL, "/")
58 | interpolatedURL := InterpolateVariables(requestStep.Request.FullURL, variables)
59 | completeURL := strings.Replace(interpolatedURL, api.BaseURLPlaceholder, finalBaseURL, 1)
60 |
61 | var req *http.Request
62 | if requestStep.Request.BodyJSON != nil {
63 | dat, err := json.Marshal(requestStep.Request.BodyJSON)
64 | cobra.CheckErr(err)
65 | interpolatedBodyJSONStr := InterpolateVariables(string(dat), variables)
66 | req, err = http.NewRequest(requestStep.Request.Method, completeURL,
67 | bytes.NewBuffer([]byte(interpolatedBodyJSONStr)),
68 | )
69 | if err != nil {
70 | cobra.CheckErr("Failed to create request")
71 | }
72 | req.Header.Add("Content-Type", "application/json")
73 | } else {
74 | var err error
75 | req, err = http.NewRequest(requestStep.Request.Method, completeURL, nil)
76 | if err != nil {
77 | cobra.CheckErr("Failed to create request")
78 | }
79 | }
80 |
81 | for k, v := range requestStep.Request.Headers {
82 | req.Header.Add(k, InterpolateVariables(v, variables))
83 | }
84 |
85 | if requestStep.Request.BasicAuth != nil {
86 | req.SetBasicAuth(requestStep.Request.BasicAuth.Username, requestStep.Request.BasicAuth.Password)
87 | }
88 |
89 | if requestStep.Request.Actions.DelayRequestByMs != nil {
90 | time.Sleep(time.Duration(*requestStep.Request.Actions.DelayRequestByMs) * time.Millisecond)
91 | }
92 |
93 | resp, err := client.Do(req)
94 | if err != nil {
95 | errString := fmt.Sprintf("Failed to fetch: %s", err.Error())
96 | result = api.HTTPRequestResult{Err: errString}
97 | return result
98 | }
99 | defer resp.Body.Close()
100 |
101 | body, err := io.ReadAll(resp.Body)
102 | if err != nil {
103 | result = api.HTTPRequestResult{Err: "Failed to read response body"}
104 | return result
105 | }
106 |
107 | headers := make(map[string]string)
108 | for k, v := range resp.Header {
109 | headers[k] = strings.Join(v, ",")
110 | }
111 |
112 | trailers := make(map[string]string)
113 | for k, v := range resp.Trailer {
114 | trailers[k] = strings.Join(v, ",")
115 | }
116 |
117 | parseVariables(body, requestStep.ResponseVariables, variables)
118 |
119 | result = api.HTTPRequestResult{
120 | StatusCode: resp.StatusCode,
121 | ResponseHeaders: headers,
122 | ResponseTrailers: trailers,
123 | BodyString: truncateAndStringifyBody(body),
124 | Variables: variables,
125 | Request: requestStep,
126 | }
127 | return result
128 | }
129 |
130 | func CLIChecks(cliData api.CLIData, overrideBaseURL string, ch chan tea.Msg) (results []api.CLIStepResult) {
131 | client := &http.Client{}
132 | variables := make(map[string]string)
133 | results = make([]api.CLIStepResult, len(cliData.Steps))
134 |
135 | if cliData.BaseURLDefault == api.BaseURLOverrideRequired && overrideBaseURL == "" {
136 | cobra.CheckErr("lesson requires a base URL override - bootdev configure base_url ")
137 | }
138 |
139 | // prefer overrideBaseURL if provided, otherwise use BaseURLDefault
140 | baseURL := overrideBaseURL
141 | if overrideBaseURL == "" {
142 | baseURL = cliData.BaseURLDefault
143 | }
144 |
145 | for i, step := range cliData.Steps {
146 | // This is the magic of the initial message sent before executing the test
147 | if step.CLICommand != nil {
148 | ch <- messages.StartStepMsg{CMD: step.CLICommand.Command}
149 | } else if step.HTTPRequest != nil {
150 | finalBaseURL := baseURL
151 | overrideURL := viper.GetString("override_base_url")
152 | if overrideURL != "" {
153 | finalBaseURL = overrideURL
154 | }
155 | fullURL := strings.Replace(step.HTTPRequest.Request.FullURL, api.BaseURLPlaceholder, finalBaseURL, 1)
156 | interpolatedURL := InterpolateVariables(fullURL, variables)
157 |
158 | ch <- messages.StartStepMsg{
159 | URL: interpolatedURL,
160 | Method: step.HTTPRequest.Request.Method,
161 | ResponseVariables: step.HTTPRequest.ResponseVariables,
162 | }
163 | }
164 |
165 | switch {
166 | case step.CLICommand != nil:
167 | result := runCLICommand(*step.CLICommand, variables)
168 | results[i].CLICommandResult = &result
169 |
170 | sendCLICommandResults(ch, *step.CLICommand, result, i)
171 |
172 | case step.HTTPRequest != nil:
173 | result := runHTTPRequest(client, baseURL, variables, *step.HTTPRequest)
174 | results[i].HTTPRequestResult = &result
175 | if result.Variables != nil {
176 | variables = result.Variables
177 | }
178 |
179 | sendHTTPRequestResults(ch, *step.HTTPRequest, result, i)
180 |
181 | default:
182 | cobra.CheckErr("unable to run lesson: missing step")
183 | }
184 | }
185 | return results
186 | }
187 |
188 | func sendCLICommandResults(ch chan tea.Msg, cmd api.CLIStepCLICommand, result api.CLICommandResult, index int) {
189 | for _, test := range cmd.Tests {
190 | ch <- messages.StartTestMsg{Text: prettyPrintCLICommand(test, result.Variables)}
191 | }
192 |
193 | for j := range cmd.Tests {
194 | ch <- messages.ResolveTestMsg{Index: j}
195 | }
196 |
197 | ch <- messages.ResolveStepMsg{
198 | Index: index,
199 | Result: &api.CLIStepResult{
200 | CLICommandResult: &result,
201 | },
202 | }
203 | }
204 |
205 | func sendHTTPRequestResults(ch chan tea.Msg, req api.CLIStepHTTPRequest, result api.HTTPRequestResult, index int) {
206 | for _, test := range req.Tests {
207 | ch <- messages.StartTestMsg{Text: prettyPrintHTTPTest(test, result.Variables)}
208 | }
209 |
210 | for j := range req.Tests {
211 | ch <- messages.ResolveTestMsg{Index: j}
212 | }
213 |
214 | ch <- messages.ResolveStepMsg{
215 | Index: index,
216 | Result: &api.CLIStepResult{
217 | HTTPRequestResult: &result,
218 | },
219 | }
220 | }
221 |
222 | func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string) string {
223 | if test.ExitCode != nil {
224 | return fmt.Sprintf("Expect exit code %d", *test.ExitCode)
225 | }
226 | if test.StdoutLinesGt != nil {
227 | return fmt.Sprintf("Expect > %d lines on stdout", *test.StdoutLinesGt)
228 | }
229 | if test.StdoutContainsAll != nil {
230 | str := "Expect stdout to contain all of:"
231 | for _, contains := range test.StdoutContainsAll {
232 | interpolatedContains := InterpolateVariables(contains, variables)
233 | str += fmt.Sprintf("\n - '%s'", interpolatedContains)
234 | }
235 | return str
236 | }
237 | if test.StdoutContainsNone != nil {
238 | str := "Expect stdout to contain none of:"
239 | for _, containsNone := range test.StdoutContainsNone {
240 | interpolatedContainsNone := InterpolateVariables(containsNone, variables)
241 | str += fmt.Sprintf("\n - '%s'", interpolatedContainsNone)
242 | }
243 | return str
244 | }
245 | return ""
246 | }
247 |
248 | func prettyPrintHTTPTest(test api.HTTPRequestTest, variables map[string]string) string {
249 | if test.StatusCode != nil {
250 | return fmt.Sprintf("Expecting status code: %d", *test.StatusCode)
251 | }
252 | if test.BodyContains != nil {
253 | interpolated := InterpolateVariables(*test.BodyContains, variables)
254 | return fmt.Sprintf("Expecting body to contain: %s", interpolated)
255 | }
256 | if test.BodyContainsNone != nil {
257 | interpolated := InterpolateVariables(*test.BodyContainsNone, variables)
258 | return fmt.Sprintf("Expecting JSON body to not contain: %s", interpolated)
259 | }
260 | if test.HeadersContain != nil {
261 | interpolatedKey := InterpolateVariables(test.HeadersContain.Key, variables)
262 | interpolatedValue := InterpolateVariables(test.HeadersContain.Value, variables)
263 | return fmt.Sprintf("Expecting headers to contain: '%s: %v'", interpolatedKey, interpolatedValue)
264 | }
265 | if test.TrailersContain != nil {
266 | interpolatedKey := InterpolateVariables(test.TrailersContain.Key, variables)
267 | interpolatedValue := InterpolateVariables(test.TrailersContain.Value, variables)
268 | return fmt.Sprintf("Expecting trailers to contain: '%s: %v'", interpolatedKey, interpolatedValue)
269 | }
270 | if test.JSONValue != nil {
271 | var val any
272 | var op any
273 | if test.JSONValue.IntValue != nil {
274 | val = *test.JSONValue.IntValue
275 | } else if test.JSONValue.StringValue != nil {
276 | val = *test.JSONValue.StringValue
277 | } else if test.JSONValue.BoolValue != nil {
278 | val = *test.JSONValue.BoolValue
279 | }
280 | if test.JSONValue.Operator == api.OpEquals {
281 | op = "to be equal to"
282 | } else if test.JSONValue.Operator == api.OpGreaterThan {
283 | op = "to be greater than"
284 | } else if test.JSONValue.Operator == api.OpContains {
285 | op = "contains"
286 | } else if test.JSONValue.Operator == api.OpNotContains {
287 | op = "to not contain"
288 | }
289 | expecting := fmt.Sprintf("Expecting JSON at %v %s %v", test.JSONValue.Path, op, val)
290 | return InterpolateVariables(expecting, variables)
291 | }
292 | return ""
293 | }
294 |
295 | // truncateAndStringifyBody
296 | // in some lessons we yeet the entire body up to the server, but we really shouldn't ever care
297 | // about more than 100,000 stringified characters of it, so this protects against giant bodies
298 | func truncateAndStringifyBody(body []byte) string {
299 | bodyString := string(body)
300 | const maxBodyLength = 1000000
301 | if len(bodyString) > maxBodyLength {
302 | bodyString = bodyString[:maxBodyLength]
303 | }
304 | return bodyString
305 | }
306 |
307 | func parseVariables(body []byte, vardefs []api.HTTPRequestResponseVariable, variables map[string]string) error {
308 | for _, vardef := range vardefs {
309 | val, err := valFromJQPath(vardef.Path, string(body))
310 | if err != nil {
311 | return err
312 | }
313 | variables[vardef.Name] = fmt.Sprintf("%v", val)
314 | }
315 | return nil
316 | }
317 |
318 | func valFromJQPath(path string, jsn string) (any, error) {
319 | vals, err := valsFromJQPath(path, jsn)
320 | if err != nil {
321 | return nil, err
322 | }
323 | if len(vals) != 1 {
324 | return nil, errors.New("invalid number of values found")
325 | }
326 | val := vals[0]
327 | if val == nil {
328 | return nil, errors.New("value not found")
329 | }
330 | return val, nil
331 | }
332 |
333 | func valsFromJQPath(path string, jsn string) ([]any, error) {
334 | var parseable any
335 | err := json.Unmarshal([]byte(jsn), &parseable)
336 | if err != nil {
337 | return nil, err
338 | }
339 |
340 | query, err := gojq.Parse(path)
341 | if err != nil {
342 | return nil, err
343 | }
344 | iter := query.Run(parseable)
345 | vals := []any{}
346 | for {
347 | v, ok := iter.Next()
348 | if !ok {
349 | break
350 | }
351 | if err, ok := v.(error); ok {
352 | if err, ok := err.(*gojq.HaltError); ok && err.Value() == nil {
353 | break
354 | }
355 | return nil, err
356 | }
357 | vals = append(vals, v)
358 | }
359 | return vals, nil
360 | }
361 |
362 | func InterpolateVariables(template string, vars map[string]string) string {
363 | r := regexp.MustCompile(`\$\{([^}]+)\}`)
364 | return r.ReplaceAllStringFunc(template, func(m string) string {
365 | // Extract the key from the match, which is in the form ${key}
366 | key := strings.TrimSuffix(strings.TrimPrefix(m, "${"), "}")
367 | if val, ok := vars[key]; ok {
368 | return val
369 | }
370 | return m // return the original placeholder if no substitution found
371 | })
372 | }
373 |
--------------------------------------------------------------------------------
/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 == 402 {
91 | return nil, fmt.Errorf("To run and submit the tests for this lesson, you must have an active Boot.dev membership\nhttps://boot.dev/pricing")
92 | }
93 | if code != 200 {
94 | return nil, fmt.Errorf("failed to %s to %s\nResponse: %d %s", method, url, code, string(body))
95 | }
96 | return body, err
97 | }
98 |
99 | func fetchWithAuthAndPayload(method string, url string, payload []byte) ([]byte, int, error) {
100 | api_url := viper.GetString("api_url")
101 | client := &http.Client{}
102 | r, err := http.NewRequest(method, api_url+url, bytes.NewBuffer(payload))
103 | if err != nil {
104 | return nil, 0, err
105 | }
106 | r.Header.Add("Authorization", "Bearer "+viper.GetString("access_token"))
107 |
108 | resp, err := client.Do(r)
109 | if err != nil {
110 | return nil, 0, err
111 | }
112 | defer resp.Body.Close()
113 |
114 | body, err := io.ReadAll(resp.Body)
115 | if err != nil {
116 | return nil, 0, err
117 | }
118 |
119 | return body, resp.StatusCode, nil
120 | }
121 |
--------------------------------------------------------------------------------
/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 | AllowedOperatingSystems []string
27 | }
28 |
29 | type CLIStep struct {
30 | CLICommand *CLIStepCLICommand
31 | HTTPRequest *CLIStepHTTPRequest
32 | }
33 |
34 | type CLIStepCLICommand struct {
35 | Command string
36 | Tests []CLICommandTest
37 | }
38 |
39 | type CLICommandTest struct {
40 | ExitCode *int
41 | StdoutContainsAll []string
42 | StdoutContainsNone []string
43 | StdoutLinesGt *int
44 | }
45 |
46 | type CLIStepHTTPRequest struct {
47 | ResponseVariables []HTTPRequestResponseVariable
48 | Tests []HTTPRequestTest
49 | Request HTTPRequest
50 | }
51 |
52 | const BaseURLPlaceholder = "${baseURL}"
53 |
54 | type HTTPRequest struct {
55 | Method string
56 | FullURL string
57 | Headers map[string]string
58 | BodyJSON map[string]any
59 |
60 | BasicAuth *HTTPBasicAuth
61 | Actions HTTPActions
62 | }
63 |
64 | type HTTPBasicAuth struct {
65 | Username string
66 | Password string
67 | }
68 |
69 | type HTTPActions struct {
70 | DelayRequestByMs *int
71 | }
72 |
73 | type HTTPRequestResponseVariable struct {
74 | Name string
75 | Path string
76 | }
77 |
78 | // Only one of these fields should be set
79 | type HTTPRequestTest struct {
80 | StatusCode *int
81 | BodyContains *string
82 | BodyContainsNone *string
83 | HeadersContain *HTTPRequestTestHeader
84 | TrailersContain *HTTPRequestTestHeader
85 | JSONValue *HTTPRequestTestJSONValue
86 | }
87 |
88 | type HTTPRequestTestHeader struct {
89 | Key string
90 | Value string
91 | }
92 |
93 | type HTTPRequestTestJSONValue struct {
94 | Path string
95 | Operator OperatorType
96 | IntValue *int
97 | StringValue *string
98 | BoolValue *bool
99 | }
100 |
101 | type OperatorType string
102 |
103 | const (
104 | OpEquals OperatorType = "eq"
105 | OpGreaterThan OperatorType = "gt"
106 | OpContains OperatorType = "contains"
107 | OpNotContains OperatorType = "not_contains"
108 | )
109 |
110 | func FetchLesson(uuid string) (*Lesson, error) {
111 | resp, err := fetchWithAuth("GET", "/v1/lessons/"+uuid)
112 | if err != nil {
113 | return nil, err
114 | }
115 |
116 | var data Lesson
117 | err = json.Unmarshal(resp, &data)
118 | if err != nil {
119 | return nil, err
120 | }
121 | return &data, nil
122 | }
123 |
124 | type CLIStepResult struct {
125 | CLICommandResult *CLICommandResult
126 | HTTPRequestResult *HTTPRequestResult
127 | }
128 |
129 | type CLICommandResult struct {
130 | ExitCode int
131 | FinalCommand string `json:"-"`
132 | Stdout string
133 | Variables map[string]string
134 | }
135 |
136 | type HTTPRequestResult struct {
137 | Err string `json:"-"`
138 | StatusCode int
139 | ResponseHeaders map[string]string
140 | ResponseTrailers map[string]string
141 | BodyString string
142 | Variables map[string]string
143 | Request CLIStepHTTPRequest
144 | }
145 |
146 | type lessonSubmissionCLI struct {
147 | CLIResults []CLIStepResult
148 | }
149 |
150 | type verificationResult struct {
151 | ResultSlug string
152 | // user friendly message to put in the toast
153 | ResultMessage string
154 | // only present if the lesson is an CLI type
155 | StructuredErrCLI *VerificationResultStructuredErrCLI
156 | }
157 |
158 | type VerificationResultStructuredErrCLI struct {
159 | ErrorMessage string `json:"Error"`
160 | FailedStepIndex int `json:"FailedStepIndex"`
161 | FailedTestIndex int `json:"FailedTestIndex"`
162 | }
163 |
164 | func SubmitCLILesson(uuid string, results []CLIStepResult) (*VerificationResultStructuredErrCLI, error) {
165 | bytes, err := json.Marshal(lessonSubmissionCLI{CLIResults: results})
166 | if err != nil {
167 | return nil, err
168 | }
169 | endpoint := fmt.Sprintf("/v1/lessons/%v/", uuid)
170 | resp, code, err := fetchWithAuthAndPayload("POST", endpoint, bytes)
171 | if err != nil {
172 | return nil, err
173 | }
174 | if code == 402 {
175 | return nil, fmt.Errorf("To run and submit the tests for this lesson, you must have an active Boot.dev membership\nhttps://boot.dev/pricing")
176 | }
177 | if code != 200 {
178 | return nil, fmt.Errorf("failed to submit CLI lesson (code: %v): %s", code, string(resp))
179 | }
180 |
181 | result := verificationResult{}
182 | err = json.Unmarshal(resp, &result)
183 | if err != nil {
184 | return nil, err
185 | }
186 | return result.StructuredErrCLI, nil
187 | }
188 |
--------------------------------------------------------------------------------
/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(w http.ResponseWriter, r *http.Request) {
153 | code, err := io.ReadAll(r.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(w http.ResponseWriter, r *http.Request) {
163 | // 200 OK
164 | }
165 |
166 | handleRedirect := func(w http.ResponseWriter, r *http.Request) {
167 | loginUrl := viper.GetString("frontend_url") + "/cli/login"
168 | http.Redirect(w, r, loginUrl, http.StatusSeeOther)
169 | }
170 |
171 | http.Handle("POST /submit", cors(http.HandlerFunc(handleSubmit)))
172 | http.Handle("/health", cors(http.HandlerFunc(handleHealth)))
173 | http.Handle("/{$}", cors(http.HandlerFunc(handleRedirect)))
174 |
175 | // if we fail, oh well. we fall back to entering the code
176 | _ = http.ListenAndServe("localhost:9417", nil)
177 | }
178 |
179 | func init() {
180 | rootCmd.AddCommand(loginCmd)
181 | }
182 |
--------------------------------------------------------------------------------
/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 | "runtime"
7 |
8 | "github.com/bootdotdev/bootdev/checks"
9 | api "github.com/bootdotdev/bootdev/client"
10 | tea "github.com/charmbracelet/bubbletea"
11 |
12 | "github.com/bootdotdev/bootdev/render"
13 | "github.com/spf13/cobra"
14 | "github.com/spf13/viper"
15 | )
16 |
17 | var forceSubmit bool
18 |
19 | func init() {
20 | rootCmd.AddCommand(submitCmd)
21 | }
22 |
23 | // submitCmd represents the submit command
24 | var submitCmd = &cobra.Command{
25 | Use: "submit UUID",
26 | Args: cobra.MatchAll(cobra.RangeArgs(1, 10)),
27 | Short: "Submit a lesson",
28 | PreRun: compose(requireUpdated, requireAuth),
29 | RunE: submissionHandler,
30 | }
31 |
32 | func submissionHandler(cmd *cobra.Command, args []string) error {
33 | cmd.SilenceUsage = true
34 | isSubmit := cmd.Name() == "submit" || forceSubmit
35 | lessonUUID := args[0]
36 |
37 | lesson, err := api.FetchLesson(lessonUUID)
38 | if err != nil {
39 | return err
40 | }
41 | if lesson.Lesson.Type != "type_cli" {
42 | return errors.New("unable to run lesson: unsupported lesson type")
43 | }
44 | if lesson.Lesson.LessonDataCLI == nil {
45 | return errors.New("unable to run lesson: missing lesson data")
46 | }
47 |
48 | data := lesson.Lesson.LessonDataCLI.CLIData
49 |
50 | isAllowedOS := false
51 | for _, system := range data.AllowedOperatingSystems {
52 | if system == runtime.GOOS {
53 | isAllowedOS = true
54 | }
55 | }
56 |
57 | if !isAllowedOS {
58 | return fmt.Errorf("lesson is not supported for your operating system: \"%s\". Try again with one of the following: %v", runtime.GOOS, data.AllowedOperatingSystems)
59 | }
60 |
61 | overrideBaseURL := viper.GetString("override_base_url")
62 | if overrideBaseURL != "" {
63 | fmt.Printf("Using overridden base_url: %v\n", overrideBaseURL)
64 | fmt.Printf("You can reset to the default with `bootdev config base_url --reset`\n\n")
65 | }
66 |
67 | ch := make(chan tea.Msg, 1)
68 | // StartRenderer and returns immediately, finalise function blocks the execution until the renderer is closed.
69 | finalise := render.StartRenderer(data, isSubmit, ch)
70 |
71 | results := checks.CLIChecks(data, overrideBaseURL, ch)
72 |
73 | if isSubmit {
74 | failure, err := api.SubmitCLILesson(lessonUUID, results)
75 | if err != nil {
76 | return err
77 | }
78 | finalise(failure)
79 | } else {
80 | finalise(nil)
81 | }
82 | return nil
83 | }
84 |
--------------------------------------------------------------------------------
/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 | package main
2 |
3 | import (
4 | "os"
5 | "strings"
6 |
7 | _ "embed"
8 |
9 | "github.com/bootdotdev/bootdev/cmd"
10 | )
11 |
12 | //go:embed version.txt
13 | var version string
14 |
15 | func main() {
16 | err := cmd.Execute(strings.TrimSpace(version))
17 | if err != nil {
18 | os.Exit(1)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/messages/messages.go:
--------------------------------------------------------------------------------
1 | package messages
2 |
3 | import api "github.com/bootdotdev/bootdev/client"
4 |
5 | type StartStepMsg struct {
6 | ResponseVariables []api.HTTPRequestResponseVariable
7 | CMD string
8 | URL string
9 | Method string
10 | }
11 |
12 | type StartTestMsg struct {
13 | Text string
14 | }
15 |
16 | type ResolveTestMsg struct {
17 | Index int
18 | Passed *bool
19 | }
20 |
21 | type DoneStepMsg struct {
22 | Failure *api.VerificationResultStructuredErrCLI
23 | }
24 |
25 | type ResolveStepMsg struct {
26 | Index int
27 | Passed *bool
28 | Result *api.CLIStepResult
29 | }
30 |
--------------------------------------------------------------------------------
/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/bootdotdev/bootdev/messages"
14 | "github.com/charmbracelet/bubbles/spinner"
15 | tea "github.com/charmbracelet/bubbletea"
16 | "github.com/charmbracelet/lipgloss"
17 | "github.com/muesli/termenv"
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 | func renderTestHeader(header string, spinner spinner.Model, isFinished bool, isSubmit bool, passed *bool) string {
33 | cmdStr := renderTest(header, spinner.View(), isFinished, &isSubmit, passed)
34 | box := borderBox.Render(fmt.Sprintf(" %s ", cmdStr))
35 | sliced := strings.Split(box, "\n")
36 | sliced[2] = strings.Replace(sliced[2], "─", "┬", 1)
37 | return strings.Join(sliced, "\n") + "\n"
38 | }
39 |
40 | func renderTestResponseVars(respVars []api.HTTPRequestResponseVariable) string {
41 | var str string
42 | for _, respVar := range respVars {
43 | varStr := gray.Render(fmt.Sprintf(" * Saving `%s` from `%s`", respVar.Name, respVar.Path))
44 | edges := " ├─"
45 | for range lipgloss.Height(varStr) - 1 {
46 | edges += "\n │ "
47 | }
48 | str += lipgloss.JoinHorizontal(lipgloss.Top, edges, varStr)
49 | str += "\n"
50 | }
51 | return str
52 | }
53 |
54 | func renderTests(tests []testModel, spinner string) string {
55 | var str string
56 | for _, test := range tests {
57 | testStr := renderTest(test.text, spinner, test.finished, nil, test.passed)
58 | testStr = fmt.Sprintf(" %s", testStr)
59 |
60 | edges := " ├─"
61 | for range lipgloss.Height(testStr) - 1 {
62 | edges += "\n │ "
63 | }
64 | str += lipgloss.JoinHorizontal(lipgloss.Top, edges, testStr)
65 | str += "\n"
66 | }
67 | return str
68 | }
69 |
70 | func renderTest(text string, spinner string, isFinished bool, isSubmit *bool, passed *bool) string {
71 | testStr := ""
72 | if !isFinished {
73 | testStr += fmt.Sprintf("%s %s", spinner, text)
74 | } else if isSubmit != nil && !*isSubmit {
75 | testStr += text
76 | } else if passed == nil {
77 | testStr += gray.Render(fmt.Sprintf("? %s", text))
78 | } else if *passed {
79 | testStr += green.Render(fmt.Sprintf("✓ %s", text))
80 | } else {
81 | testStr += red.Render(fmt.Sprintf("X %s", text))
82 | }
83 | return testStr
84 | }
85 |
86 | type stepModel struct {
87 | responseVariables []api.HTTPRequestResponseVariable
88 | step string
89 | passed *bool
90 | result *api.CLIStepResult
91 | finished bool
92 | tests []testModel
93 | }
94 |
95 | type rootModel struct {
96 | steps []stepModel
97 | spinner spinner.Model
98 | failure *api.VerificationResultStructuredErrCLI
99 | isSubmit bool
100 | success bool
101 | finalized bool
102 | clear bool
103 | }
104 |
105 | func initModel(isSubmit bool) rootModel {
106 | s := spinner.New()
107 | s.Spinner = spinner.Dot
108 | return rootModel{
109 | spinner: s,
110 | isSubmit: isSubmit,
111 | steps: []stepModel{},
112 | }
113 | }
114 |
115 | func (m rootModel) Init() tea.Cmd {
116 | green = lipgloss.NewStyle().Foreground(lipgloss.Color(viper.GetString("color.green")))
117 | red = lipgloss.NewStyle().Foreground(lipgloss.Color(viper.GetString("color.red")))
118 | gray = lipgloss.NewStyle().Foreground(lipgloss.Color(viper.GetString("color.gray")))
119 | return m.spinner.Tick
120 | }
121 |
122 | func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
123 | switch msg := msg.(type) {
124 | case messages.DoneStepMsg:
125 | m.failure = msg.Failure
126 | if m.failure == nil && m.isSubmit {
127 | m.success = true
128 | }
129 | m.clear = true
130 | return m, tea.Quit
131 |
132 | case messages.StartStepMsg:
133 | step := fmt.Sprintf("Running: %s", msg.CMD)
134 | if msg.CMD == "" {
135 | step = fmt.Sprintf("%s %s", msg.Method, msg.URL)
136 | }
137 | m.steps = append(m.steps, stepModel{
138 | step: step,
139 | tests: []testModel{},
140 | responseVariables: msg.ResponseVariables,
141 | })
142 | return m, nil
143 |
144 | case messages.ResolveStepMsg:
145 | m.steps[msg.Index].passed = msg.Passed
146 | m.steps[msg.Index].finished = true
147 | m.steps[msg.Index].result = msg.Result
148 | return m, nil
149 |
150 | case messages.StartTestMsg:
151 | m.steps[len(m.steps)-1].tests = append(
152 | m.steps[len(m.steps)-1].tests,
153 | testModel{text: msg.Text},
154 | )
155 | return m, nil
156 |
157 | case messages.ResolveTestMsg:
158 | m.steps[len(m.steps)-1].tests[msg.Index].passed = msg.Passed
159 | m.steps[len(m.steps)-1].tests[msg.Index].finished = true
160 | return m, nil
161 |
162 | default:
163 | var cmd tea.Cmd
164 | m.spinner, cmd = m.spinner.Update(msg)
165 | return m, cmd
166 | }
167 | }
168 |
169 | func (m rootModel) View() string {
170 | if m.clear {
171 | return ""
172 | }
173 | s := m.spinner.View()
174 | var str string
175 | for _, step := range m.steps {
176 | str += renderTestHeader(step.step, m.spinner, step.finished, m.isSubmit, step.passed)
177 | str += renderTests(step.tests, s)
178 | str += renderTestResponseVars(step.responseVariables)
179 | if step.result == nil || !m.finalized {
180 | continue
181 | }
182 |
183 | if step.result.CLICommandResult != nil {
184 | // render the results
185 | for _, test := range step.tests {
186 | // for clarity, only show exit code if it's tested
187 | if strings.Contains(test.text, "exit code") {
188 | str += fmt.Sprintf("\n > Command exit code: %d\n", step.result.CLICommandResult.ExitCode)
189 | break
190 | }
191 | }
192 | str += " > Command stdout:\n\n"
193 | sliced := strings.Split(step.result.CLICommandResult.Stdout, "\n")
194 | for _, s := range sliced {
195 | str += gray.Render(s) + "\n"
196 | }
197 |
198 | }
199 |
200 | if step.result.HTTPRequestResult != nil {
201 | str += printHTTPRequestResult(*step.result.HTTPRequestResult)
202 | }
203 | }
204 | if m.failure != nil {
205 | str += red.Render("\n\nError: "+m.failure.ErrorMessage) + "\n\n"
206 | } else if m.success {
207 | str += "\n\n" + green.Render("All tests passed! 🎉") + "\n\n"
208 | str += green.Render("Return to your browser to continue with the next lesson.") + "\n\n"
209 | }
210 | return str
211 | }
212 |
213 | func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string) string {
214 | if test.ExitCode != nil {
215 | return fmt.Sprintf("Expect exit code %d", *test.ExitCode)
216 | }
217 | if test.StdoutLinesGt != nil {
218 | return fmt.Sprintf("Expect > %d lines on stdout", *test.StdoutLinesGt)
219 | }
220 | if test.StdoutContainsAll != nil {
221 | str := "Expect stdout to contain all of:"
222 | for _, contains := range test.StdoutContainsAll {
223 | interpolatedContains := checks.InterpolateVariables(contains, variables)
224 | str += fmt.Sprintf("\n - '%s'", interpolatedContains)
225 | }
226 | return str
227 | }
228 | if test.StdoutContainsNone != nil {
229 | str := "Expect stdout to contain none of:"
230 | for _, containsNone := range test.StdoutContainsNone {
231 | interpolatedContainsNone := checks.InterpolateVariables(containsNone, variables)
232 | str += fmt.Sprintf("\n - '%s'", interpolatedContainsNone)
233 | }
234 | return str
235 | }
236 | return ""
237 | }
238 |
239 | func pointerToBool(a bool) *bool {
240 | return &a
241 | }
242 |
243 | func printHTTPRequestResult(result api.HTTPRequestResult) string {
244 | if result.Err != "" {
245 | return fmt.Sprintf(" Err: %v\n\n", result.Err)
246 | }
247 |
248 | str := ""
249 |
250 | str += fmt.Sprintf(" Response Status Code: %v\n", result.StatusCode)
251 |
252 | filteredHeaders := make(map[string]string)
253 | for respK, respV := range result.ResponseHeaders {
254 | for _, test := range result.Request.Tests {
255 | if test.HeadersContain == nil {
256 | continue
257 | }
258 | interpolatedTestHeaderKey := checks.InterpolateVariables(test.HeadersContain.Key, result.Variables)
259 | if strings.EqualFold(respK, interpolatedTestHeaderKey) {
260 | filteredHeaders[respK] = respV
261 | }
262 | }
263 | }
264 |
265 | filteredTrailers := make(map[string]string)
266 | for respK, respV := range result.ResponseTrailers {
267 | for _, test := range result.Request.Tests {
268 | if test.TrailersContain == nil {
269 | continue
270 | }
271 |
272 | interpolatedTestTrailerKey := checks.InterpolateVariables(test.TrailersContain.Key, result.Variables)
273 | if strings.EqualFold(respK, interpolatedTestTrailerKey) {
274 | filteredTrailers[respK] = respV
275 | }
276 | }
277 | }
278 |
279 | if len(filteredHeaders) > 0 {
280 | str += " Response Headers: \n"
281 | for k, v := range filteredHeaders {
282 | str += fmt.Sprintf(" - %v: %v\n", k, v)
283 | }
284 | }
285 |
286 | str += " Response Body: \n"
287 | bytes := []byte(result.BodyString)
288 | contentType := http.DetectContentType(bytes)
289 | if contentType == "application/json" || strings.HasPrefix(contentType, "text/") {
290 | var unmarshalled any
291 | err := json.Unmarshal([]byte(result.BodyString), &unmarshalled)
292 | if err == nil {
293 | pretty, err := json.MarshalIndent(unmarshalled, "", " ")
294 | if err == nil {
295 | str += string(pretty)
296 | } else {
297 | str += result.BodyString
298 | }
299 | } else {
300 | str += result.BodyString
301 | }
302 | } else {
303 | str += fmt.Sprintf(
304 | "Binary %s file. Raw data hidden. To manually debug, use curl -o myfile.bin and inspect the file",
305 | contentType,
306 | )
307 | }
308 | str += "\n"
309 |
310 | if len(filteredTrailers) > 0 {
311 | str += " Response Trailers: \n"
312 | for k, v := range filteredTrailers {
313 | str += fmt.Sprintf(" - %v: %v\n", k, v)
314 | }
315 | }
316 |
317 | if len(result.Variables) > 0 {
318 | str += " Variables available: \n"
319 | for k, v := range result.Variables {
320 | if v != "" {
321 | str += fmt.Sprintf(" - %v: %v\n", k, v)
322 | } else {
323 | str += fmt.Sprintf(" - %v: [not found]\n", k)
324 | }
325 | }
326 | }
327 | str += "\n"
328 |
329 | return str
330 | }
331 |
332 | func StartRenderer(data api.CLIData, isSubmit bool, ch chan tea.Msg) func(*api.VerificationResultStructuredErrCLI) {
333 | var wg sync.WaitGroup
334 | p := tea.NewProgram(initModel(isSubmit), tea.WithoutSignalHandler())
335 |
336 | wg.Add(1)
337 | go func() {
338 | defer wg.Done()
339 | if model, err := p.Run(); err != nil {
340 | fmt.Fprintln(os.Stderr, err)
341 | } else if r, ok := model.(rootModel); ok {
342 | r.clear = false
343 | r.finalized = true
344 | output := termenv.NewOutput(os.Stdout)
345 | output.WriteString(r.View())
346 | }
347 | }()
348 |
349 | go func() {
350 | for {
351 | msg := <-ch
352 | p.Send(msg)
353 | }
354 | }()
355 |
356 | return func(failure *api.VerificationResultStructuredErrCLI) {
357 | ch <- messages.DoneStepMsg{Failure: failure}
358 | wg.Wait()
359 | }
360 | }
361 |
362 | func renderCLICommand(
363 | cmd api.CLIStepCLICommand,
364 | result api.CLICommandResult,
365 | failure *api.VerificationResultStructuredErrCLI,
366 | isSubmit bool,
367 | ch chan tea.Msg,
368 | index int,
369 | ) {
370 | for _, test := range cmd.Tests {
371 | ch <- messages.StartTestMsg{Text: prettyPrintCLICommand(test, result.Variables)}
372 | }
373 |
374 | earlierCmdFailed := false
375 | if failure != nil {
376 | earlierCmdFailed = failure.FailedStepIndex < index
377 | }
378 | for j := range cmd.Tests {
379 | earlierTestFailed := false
380 | if failure != nil {
381 | if earlierCmdFailed {
382 | earlierTestFailed = true
383 | } else if failure.FailedStepIndex == index {
384 | earlierTestFailed = failure.FailedTestIndex < j
385 | }
386 | }
387 | if !isSubmit {
388 | ch <- messages.ResolveTestMsg{Index: j}
389 | } else if earlierTestFailed {
390 | ch <- messages.ResolveTestMsg{Index: j}
391 | } else {
392 | passed := failure == nil || failure.FailedStepIndex != index || failure.FailedTestIndex != j
393 | ch <- messages.ResolveTestMsg{
394 | Index: j,
395 | Passed: pointerToBool(passed),
396 | }
397 | }
398 | }
399 |
400 | if !isSubmit {
401 | ch <- messages.ResolveStepMsg{
402 | Index: index,
403 | Result: &api.CLIStepResult{
404 | CLICommandResult: &result,
405 | },
406 | }
407 | } else if earlierCmdFailed {
408 | ch <- messages.ResolveStepMsg{Index: index}
409 | } else {
410 | passed := failure == nil || failure.FailedStepIndex != index
411 | if passed {
412 | ch <- messages.ResolveStepMsg{
413 | Index: index,
414 | Passed: pointerToBool(passed),
415 | }
416 | } else {
417 | ch <- messages.ResolveStepMsg{
418 | Index: index,
419 | Passed: pointerToBool(passed),
420 | Result: &api.CLIStepResult{
421 | CLICommandResult: &result,
422 | },
423 | }
424 | }
425 | }
426 | }
427 |
428 | func renderHTTPRequest(
429 | req api.CLIStepHTTPRequest,
430 | result api.HTTPRequestResult,
431 | failure *api.VerificationResultStructuredErrCLI,
432 | isSubmit bool,
433 | baseURLDefault string,
434 | ch chan tea.Msg,
435 | index int,
436 | ) {
437 | for _, test := range req.Tests {
438 | ch <- messages.StartTestMsg{Text: prettyPrintHTTPTest(test, result.Variables)}
439 | }
440 |
441 | for j := range req.Tests {
442 | if !isSubmit {
443 | ch <- messages.ResolveTestMsg{Index: j}
444 | } else if failure != nil && (failure.FailedStepIndex < index || (failure.FailedStepIndex == index && failure.FailedTestIndex < j)) {
445 | ch <- messages.ResolveTestMsg{Index: j}
446 | } else {
447 | ch <- messages.ResolveTestMsg{Index: j, Passed: pointerToBool(failure == nil || !(failure.FailedStepIndex == index && failure.FailedTestIndex == j))}
448 | }
449 | }
450 |
451 | if !isSubmit {
452 | ch <- messages.ResolveStepMsg{
453 | Index: index,
454 | Result: &api.CLIStepResult{
455 | HTTPRequestResult: &result,
456 | },
457 | }
458 | } else if failure != nil && failure.FailedStepIndex < index {
459 | ch <- messages.ResolveStepMsg{Index: index}
460 | } else {
461 | passed := failure == nil || failure.FailedStepIndex != index
462 | if passed {
463 | ch <- messages.ResolveStepMsg{
464 | Index: index,
465 | Passed: pointerToBool(passed),
466 | }
467 | } else {
468 | ch <- messages.ResolveStepMsg{
469 | Index: index,
470 | Passed: pointerToBool(passed),
471 | Result: &api.CLIStepResult{
472 | HTTPRequestResult: &result,
473 | },
474 | }
475 | }
476 | }
477 | }
478 |
479 | func prettyPrintHTTPTest(test api.HTTPRequestTest, variables map[string]string) string {
480 | if test.StatusCode != nil {
481 | return fmt.Sprintf("Expecting status code: %d", *test.StatusCode)
482 | }
483 | if test.BodyContains != nil {
484 | interpolated := checks.InterpolateVariables(*test.BodyContains, variables)
485 | return fmt.Sprintf("Expecting body to contain: %s", interpolated)
486 | }
487 | if test.BodyContainsNone != nil {
488 | interpolated := checks.InterpolateVariables(*test.BodyContainsNone, variables)
489 | return fmt.Sprintf("Expecting JSON body to not contain: %s", interpolated)
490 | }
491 | if test.HeadersContain != nil {
492 | interpolatedKey := checks.InterpolateVariables(test.HeadersContain.Key, variables)
493 | interpolatedValue := checks.InterpolateVariables(test.HeadersContain.Value, variables)
494 | return fmt.Sprintf("Expecting headers to contain: '%s: %v'", interpolatedKey, interpolatedValue)
495 | }
496 | if test.TrailersContain != nil {
497 | interpolatedKey := checks.InterpolateVariables(test.TrailersContain.Key, variables)
498 | interpolatedValue := checks.InterpolateVariables(test.TrailersContain.Value, variables)
499 | return fmt.Sprintf("Expecting trailers to contain: '%s: %v'", interpolatedKey, interpolatedValue)
500 | }
501 | if test.JSONValue != nil {
502 | var val any
503 | var op any
504 | if test.JSONValue.IntValue != nil {
505 | val = *test.JSONValue.IntValue
506 | } else if test.JSONValue.StringValue != nil {
507 | val = *test.JSONValue.StringValue
508 | } else if test.JSONValue.BoolValue != nil {
509 | val = *test.JSONValue.BoolValue
510 | }
511 | if test.JSONValue.Operator == api.OpEquals {
512 | op = "to be equal to"
513 | } else if test.JSONValue.Operator == api.OpGreaterThan {
514 | op = "to be greater than"
515 | } else if test.JSONValue.Operator == api.OpContains {
516 | op = "contains"
517 | } else if test.JSONValue.Operator == api.OpNotContains {
518 | op = "to not contain"
519 | }
520 | expecting := fmt.Sprintf("Expecting JSON at %v %s %v", test.JSONValue.Path, op, val)
521 | return checks.InterpolateVariables(expecting, variables)
522 | }
523 | return ""
524 | }
525 |
--------------------------------------------------------------------------------
/version.txt:
--------------------------------------------------------------------------------
1 | v1.20.4
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 |
--------------------------------------------------------------------------------