├── version.txt ├── main.go ├── version ├── context.go └── version.go ├── .github └── workflows │ └── main.yml ├── .gitignore ├── cmd ├── run.go ├── logout.go ├── upgrade.go ├── status.go ├── submit.go ├── root.go ├── configure.go └── login.go ├── messages └── messages.go ├── LICENSE ├── go.mod ├── client ├── auth.go └── lessons.go ├── README.md ├── render └── render.go ├── go.sum └── checks └── checks.go /version.txt: -------------------------------------------------------------------------------- 1 | v1.21.1 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | bootdev 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Code coverage profiles and other test artifacts 13 | *.out 14 | coverage.* 15 | *.coverprofile 16 | profile.cov 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | go.work.sum 24 | 25 | # env file 26 | .env 27 | 28 | # Editor/IDE 29 | # .idea/ 30 | # .vscode/ 31 | -------------------------------------------------------------------------------- /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 after running") 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 | -------------------------------------------------------------------------------- /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 | StepIndex int 18 | TestIndex int 19 | Passed *bool 20 | } 21 | 22 | type DoneStepMsg struct { 23 | Failure *api.VerificationResultStructuredErrCLI 24 | } 25 | 26 | type ResolveStepMsg struct { 27 | Index int 28 | Passed *bool 29 | Result *api.CLIStepResult 30 | } 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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: "Install 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 | 24 | fmt.Println("Upgrading Boot.dev CLI...") 25 | 26 | command := exec.Command("go", "install", "github.com/bootdotdev/bootdev@latest") 27 | command.Stdout = os.Stdout 28 | command.Stderr = os.Stderr 29 | err := command.Run() 30 | cobra.CheckErr(err) 31 | 32 | // Get new version info 33 | command = exec.Command("bootdev", "--version") 34 | versionBytes, err := command.Output() 35 | cobra.CheckErr(err) 36 | 37 | re := regexp.MustCompile(`v\d+\.\d+\.\d+`) 38 | newVersion := re.FindString(string(versionBytes)) 39 | if newVersion == "" { 40 | newVersion = "latest" 41 | } 42 | 43 | fmt.Printf("Successfully upgraded to %s!\n", newVersion) 44 | os.Exit(0) // in case old version is still running 45 | }, 46 | } 47 | 48 | func init() { 49 | rootCmd.AddCommand(upgradeCmd) 50 | } 51 | -------------------------------------------------------------------------------- /cmd/status.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | api "github.com/bootdotdev/bootdev/client" 7 | "github.com/bootdotdev/bootdev/version" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | var statusCmd = &cobra.Command{ 13 | Use: "status", 14 | Short: "Show authentication and CLI version status", 15 | Long: "Display whether you're logged in and whether the CLI is up to date", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | checkAuthStatus() 18 | fmt.Println() // Blank line for readability 19 | checkVersionStatus(cmd) 20 | }, 21 | } 22 | 23 | func checkAuthStatus() { 24 | refreshToken := viper.GetString("refresh_token") 25 | if refreshToken == "" { 26 | fmt.Println("Not logged in") 27 | fmt.Println("Run 'bootdev login' to authenticate") 28 | return 29 | } 30 | 31 | // Verify token is still valid by attempting to refresh 32 | _, err := api.FetchAccessToken() 33 | if err != nil { 34 | fmt.Println("Authentication expired") 35 | fmt.Println("Run 'bootdev login' to re-authenticate") 36 | return 37 | } 38 | 39 | fmt.Println("Logged in") 40 | // TODO: Consider adding user data endpoint to show email/username 41 | } 42 | 43 | func checkVersionStatus(cmd *cobra.Command) { 44 | info := version.FromContext(cmd.Context()) 45 | if info == nil || info.FailedToFetch != nil { 46 | fmt.Println("Unable to check version status") 47 | if info != nil && info.FailedToFetch != nil { 48 | fmt.Printf("Error: %s\n", info.FailedToFetch.Error()) 49 | } 50 | return 51 | } 52 | 53 | if info.IsOutdated { 54 | fmt.Printf("CLI outdated: %s → %s available\n", info.CurrentVersion, info.LatestVersion) 55 | fmt.Println("Run 'bootdev upgrade' to update") 56 | } else { 57 | fmt.Printf("CLI up to date (%s)\n", info.CurrentVersion) 58 | } 59 | } 60 | 61 | func init() { 62 | rootCmd.AddCommand(statusCmd) 63 | } 64 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bootdotdev/bootdev 2 | 3 | go 1.22.1 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.18.0 7 | github.com/charmbracelet/bubbletea v0.26.1 8 | github.com/charmbracelet/lipgloss v0.10.0 9 | github.com/itchyny/gojq v0.12.15 10 | github.com/muesli/termenv v0.15.2 11 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c 12 | github.com/spf13/cobra v1.8.0 13 | github.com/spf13/viper v1.18.2 14 | golang.org/x/mod v0.17.0 15 | golang.org/x/term v0.19.0 16 | ) 17 | 18 | require ( 19 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 20 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 21 | github.com/fsnotify/fsnotify v1.7.0 // indirect 22 | github.com/hashicorp/hcl v1.0.0 // indirect 23 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 24 | github.com/itchyny/timefmt-go v0.1.5 // indirect 25 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 26 | github.com/magiconair/properties v1.8.7 // indirect 27 | github.com/mattn/go-isatty v0.0.20 // indirect 28 | github.com/mattn/go-localereader v0.0.1 // indirect 29 | github.com/mattn/go-runewidth v0.0.15 // indirect 30 | github.com/mitchellh/mapstructure v1.5.0 // indirect 31 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 32 | github.com/muesli/cancelreader v0.2.2 // indirect 33 | github.com/muesli/reflow v0.3.0 // indirect 34 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 35 | github.com/rivo/uniseg v0.4.7 // indirect 36 | github.com/sagikazarmark/locafero v0.4.0 // indirect 37 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 38 | github.com/sourcegraph/conc v0.3.0 // indirect 39 | github.com/spf13/afero v1.11.0 // indirect 40 | github.com/spf13/cast v1.6.0 // indirect 41 | github.com/spf13/pflag v1.0.5 // indirect 42 | github.com/subosito/gotenv v1.6.0 // indirect 43 | go.uber.org/atomic v1.9.0 // indirect 44 | go.uber.org/multierr v1.9.0 // indirect 45 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 46 | golang.org/x/sync v0.7.0 // indirect 47 | golang.org/x/sys 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 | -------------------------------------------------------------------------------- /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 | checks.ApplySubmissionResults(data, failure, ch) 79 | 80 | finalise(failure) 81 | } else { 82 | finalise(nil) 83 | } 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /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 and 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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: "Official Boot.dev CLI", 21 | Long: `The official CLI for Boot.dev. This program is meant 22 | as 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 $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/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("Colors reset!") 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("Base URL reset!") 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | if msg.Result != nil { 148 | m.steps[msg.Index].result = msg.Result 149 | } 150 | return m, nil 151 | 152 | case messages.StartTestMsg: 153 | m.steps[len(m.steps)-1].tests = append( 154 | m.steps[len(m.steps)-1].tests, 155 | testModel{text: msg.Text}, 156 | ) 157 | return m, nil 158 | 159 | case messages.ResolveTestMsg: 160 | m.steps[msg.StepIndex].tests[msg.TestIndex].passed = msg.Passed 161 | m.steps[msg.StepIndex].tests[msg.TestIndex].finished = true 162 | return m, nil 163 | 164 | default: 165 | var cmd tea.Cmd 166 | m.spinner, cmd = m.spinner.Update(msg) 167 | return m, cmd 168 | } 169 | } 170 | 171 | func (m rootModel) View() string { 172 | if m.clear { 173 | return "" 174 | } 175 | s := m.spinner.View() 176 | var str string 177 | for _, step := range m.steps { 178 | str += renderTestHeader(step.step, m.spinner, step.finished, m.isSubmit, step.passed) 179 | str += renderTests(step.tests, s) 180 | str += renderTestResponseVars(step.responseVariables) 181 | if step.result == nil || !m.finalized { 182 | continue 183 | } 184 | 185 | if step.result.CLICommandResult != nil { 186 | // render the results 187 | for _, test := range step.tests { 188 | // for clarity, only show exit code if it's tested 189 | if strings.Contains(test.text, "exit code") { 190 | str += fmt.Sprintf("\n > Command exit code: %d\n", step.result.CLICommandResult.ExitCode) 191 | break 192 | } 193 | } 194 | str += " > Command stdout:\n\n" 195 | sliced := strings.Split(step.result.CLICommandResult.Stdout, "\n") 196 | for _, s := range sliced { 197 | str += gray.Render(s) + "\n" 198 | } 199 | 200 | } 201 | 202 | if step.result.HTTPRequestResult != nil { 203 | str += printHTTPRequestResult(*step.result.HTTPRequestResult) 204 | } 205 | } 206 | if m.failure != nil { 207 | str += "\n\n" + red.Render("Tests failed! ❌") 208 | str += red.Render(fmt.Sprintf("\n\nFailed Step: %v", m.failure.FailedStepIndex+1)) 209 | str += red.Render("\nError: "+m.failure.ErrorMessage) + "\n\n" 210 | } else if m.success { 211 | str += "\n\n" + green.Render("All tests passed! 🎉") + "\n\n" 212 | str += green.Render("Return to your browser to continue with the next lesson.") + "\n\n" 213 | } 214 | return str 215 | } 216 | 217 | func printHTTPRequestResult(result api.HTTPRequestResult) string { 218 | if result.Err != "" { 219 | return fmt.Sprintf(" Err: %v\n\n", result.Err) 220 | } 221 | 222 | str := "" 223 | 224 | str += fmt.Sprintf(" Response Status Code: %v\n", result.StatusCode) 225 | 226 | filteredHeaders := make(map[string]string) 227 | for respK, respV := range result.ResponseHeaders { 228 | for _, test := range result.Request.Tests { 229 | if test.HeadersContain == nil { 230 | continue 231 | } 232 | interpolatedTestHeaderKey := checks.InterpolateVariables(test.HeadersContain.Key, result.Variables) 233 | if strings.EqualFold(respK, interpolatedTestHeaderKey) { 234 | filteredHeaders[respK] = respV 235 | } 236 | } 237 | } 238 | 239 | filteredTrailers := make(map[string]string) 240 | for respK, respV := range result.ResponseTrailers { 241 | for _, test := range result.Request.Tests { 242 | if test.TrailersContain == nil { 243 | continue 244 | } 245 | 246 | interpolatedTestTrailerKey := checks.InterpolateVariables(test.TrailersContain.Key, result.Variables) 247 | if strings.EqualFold(respK, interpolatedTestTrailerKey) { 248 | filteredTrailers[respK] = respV 249 | } 250 | } 251 | } 252 | 253 | if len(filteredHeaders) > 0 { 254 | str += " Response Headers: \n" 255 | for k, v := range filteredHeaders { 256 | str += fmt.Sprintf(" - %v: %v\n", k, v) 257 | } 258 | } 259 | 260 | str += " Response Body: \n" 261 | bytes := []byte(result.BodyString) 262 | contentType := http.DetectContentType(bytes) 263 | if contentType == "application/json" || strings.HasPrefix(contentType, "text/") { 264 | var unmarshalled any 265 | err := json.Unmarshal([]byte(result.BodyString), &unmarshalled) 266 | if err == nil { 267 | pretty, err := json.MarshalIndent(unmarshalled, "", " ") 268 | if err == nil { 269 | str += string(pretty) 270 | } else { 271 | str += result.BodyString 272 | } 273 | } else { 274 | str += result.BodyString 275 | } 276 | } else { 277 | str += fmt.Sprintf( 278 | "Binary %s file. Raw data hidden. To manually debug, use curl -o myfile.bin and inspect the file", 279 | contentType, 280 | ) 281 | } 282 | str += "\n" 283 | 284 | if len(filteredTrailers) > 0 { 285 | str += " Response Trailers: \n" 286 | for k, v := range filteredTrailers { 287 | str += fmt.Sprintf(" - %v: %v\n", k, v) 288 | } 289 | } 290 | 291 | if len(result.Variables) > 0 { 292 | str += " Variables available: \n" 293 | for k, v := range result.Variables { 294 | if v != "" { 295 | str += fmt.Sprintf(" - %v: %v\n", k, v) 296 | } else { 297 | str += fmt.Sprintf(" - %v: [not found]\n", k) 298 | } 299 | } 300 | } 301 | str += "\n" 302 | 303 | return str 304 | } 305 | 306 | func StartRenderer(data api.CLIData, isSubmit bool, ch chan tea.Msg) func(*api.VerificationResultStructuredErrCLI) { 307 | var wg sync.WaitGroup 308 | p := tea.NewProgram(initModel(isSubmit), tea.WithoutSignalHandler()) 309 | 310 | wg.Add(1) 311 | go func() { 312 | defer wg.Done() 313 | if model, err := p.Run(); err != nil { 314 | fmt.Fprintln(os.Stderr, err) 315 | } else if r, ok := model.(rootModel); ok { 316 | r.clear = false 317 | r.finalized = true 318 | output := termenv.NewOutput(os.Stdout) 319 | output.WriteString(r.View()) 320 | } 321 | }() 322 | 323 | go func() { 324 | for { 325 | msg := <-ch 326 | p.Send(msg) 327 | } 328 | }() 329 | 330 | return func(failure *api.VerificationResultStructuredErrCLI) { 331 | ch <- messages.DoneStepMsg{Failure: failure} 332 | wg.Wait() 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /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.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 109 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 110 | golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= 111 | golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= 112 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 113 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 114 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 115 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 116 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 117 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 118 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 119 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 120 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 121 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 122 | -------------------------------------------------------------------------------- /checks/checks.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "maps" 10 | "net/http" 11 | "os" 12 | "os/exec" 13 | "regexp" 14 | "runtime" 15 | "strings" 16 | "time" 17 | 18 | api "github.com/bootdotdev/bootdev/client" 19 | "github.com/bootdotdev/bootdev/messages" 20 | tea "github.com/charmbracelet/bubbletea" 21 | "github.com/itchyny/gojq" 22 | "github.com/spf13/cobra" 23 | "github.com/spf13/viper" 24 | ) 25 | 26 | func runCLICommand(command api.CLIStepCLICommand, variables map[string]string) (result api.CLICommandResult) { 27 | finalCommand := InterpolateVariables(command.Command, variables) 28 | result.FinalCommand = finalCommand 29 | 30 | var cmd *exec.Cmd 31 | 32 | if runtime.GOOS == "windows" { 33 | cmd = exec.Command("powershell", "-Command", finalCommand) 34 | } else { 35 | cmd = exec.Command("sh", "-c", finalCommand) 36 | } 37 | 38 | cmd.Env = append(os.Environ(), "LANG=en_US.UTF-8") 39 | b, err := cmd.CombinedOutput() 40 | if ee, ok := err.(*exec.ExitError); ok { 41 | result.ExitCode = ee.ExitCode() 42 | } else if err != nil { 43 | result.ExitCode = -2 44 | } 45 | result.Stdout = strings.TrimRight(string(b), " \n\t\r") 46 | result.Variables = maps.Clone(variables) 47 | return result 48 | } 49 | 50 | func runHTTPRequest( 51 | client *http.Client, 52 | baseURL string, 53 | variables map[string]string, 54 | requestStep api.CLIStepHTTPRequest, 55 | ) ( 56 | result api.HTTPRequestResult, 57 | ) { 58 | finalBaseURL := strings.TrimSuffix(baseURL, "/") 59 | interpolatedURL := InterpolateVariables(requestStep.Request.FullURL, variables) 60 | completeURL := strings.Replace(interpolatedURL, api.BaseURLPlaceholder, finalBaseURL, 1) 61 | 62 | var req *http.Request 63 | if requestStep.Request.BodyJSON != nil { 64 | dat, err := json.Marshal(requestStep.Request.BodyJSON) 65 | cobra.CheckErr(err) 66 | interpolatedBodyJSONStr := InterpolateVariables(string(dat), variables) 67 | req, err = http.NewRequest(requestStep.Request.Method, completeURL, 68 | bytes.NewBuffer([]byte(interpolatedBodyJSONStr)), 69 | ) 70 | if err != nil { 71 | cobra.CheckErr("Failed to create request") 72 | } 73 | req.Header.Add("Content-Type", "application/json") 74 | } else { 75 | var err error 76 | req, err = http.NewRequest(requestStep.Request.Method, completeURL, nil) 77 | if err != nil { 78 | cobra.CheckErr("Failed to create request") 79 | } 80 | } 81 | 82 | for k, v := range requestStep.Request.Headers { 83 | req.Header.Add(k, InterpolateVariables(v, variables)) 84 | } 85 | 86 | if requestStep.Request.BasicAuth != nil { 87 | req.SetBasicAuth(requestStep.Request.BasicAuth.Username, requestStep.Request.BasicAuth.Password) 88 | } 89 | 90 | if requestStep.Request.Actions.DelayRequestByMs != nil { 91 | time.Sleep(time.Duration(*requestStep.Request.Actions.DelayRequestByMs) * time.Millisecond) 92 | } 93 | 94 | resp, err := client.Do(req) 95 | if err != nil { 96 | errString := fmt.Sprintf("Failed to fetch: %s", err.Error()) 97 | result = api.HTTPRequestResult{Err: errString} 98 | return result 99 | } 100 | defer resp.Body.Close() 101 | 102 | body, err := io.ReadAll(resp.Body) 103 | if err != nil { 104 | result = api.HTTPRequestResult{Err: "Failed to read response body"} 105 | return result 106 | } 107 | 108 | headers := make(map[string]string) 109 | for k, v := range resp.Header { 110 | headers[k] = strings.Join(v, ",") 111 | } 112 | 113 | trailers := make(map[string]string) 114 | for k, v := range resp.Trailer { 115 | trailers[k] = strings.Join(v, ",") 116 | } 117 | 118 | parseVariables(body, requestStep.ResponseVariables, variables) 119 | 120 | result = api.HTTPRequestResult{ 121 | StatusCode: resp.StatusCode, 122 | ResponseHeaders: headers, 123 | ResponseTrailers: trailers, 124 | BodyString: truncateAndStringifyBody(body), 125 | Variables: maps.Clone(variables), 126 | Request: requestStep, 127 | } 128 | return result 129 | } 130 | 131 | func CLIChecks(cliData api.CLIData, overrideBaseURL string, ch chan tea.Msg) (results []api.CLIStepResult) { 132 | client := &http.Client{} 133 | variables := make(map[string]string) 134 | results = make([]api.CLIStepResult, len(cliData.Steps)) 135 | 136 | if cliData.BaseURLDefault == api.BaseURLOverrideRequired && overrideBaseURL == "" { 137 | cobra.CheckErr("lesson requires a base URL override: `bootdev configure base_url `") 138 | } 139 | 140 | // prefer overrideBaseURL if provided, otherwise use BaseURLDefault 141 | baseURL := overrideBaseURL 142 | if overrideBaseURL == "" { 143 | baseURL = cliData.BaseURLDefault 144 | } 145 | 146 | for i, step := range cliData.Steps { 147 | // This is the magic of the initial message sent before executing the test 148 | if step.CLICommand != nil { 149 | ch <- messages.StartStepMsg{CMD: step.CLICommand.Command} 150 | } else if step.HTTPRequest != nil { 151 | finalBaseURL := baseURL 152 | overrideURL := viper.GetString("override_base_url") 153 | if overrideURL != "" { 154 | finalBaseURL = overrideURL 155 | } 156 | fullURL := strings.Replace(step.HTTPRequest.Request.FullURL, api.BaseURLPlaceholder, finalBaseURL, 1) 157 | interpolatedURL := InterpolateVariables(fullURL, variables) 158 | 159 | ch <- messages.StartStepMsg{ 160 | URL: interpolatedURL, 161 | Method: step.HTTPRequest.Request.Method, 162 | ResponseVariables: step.HTTPRequest.ResponseVariables, 163 | } 164 | } 165 | 166 | switch { 167 | case step.CLICommand != nil: 168 | result := runCLICommand(*step.CLICommand, variables) 169 | results[i].CLICommandResult = &result 170 | 171 | sendCLICommandResults(ch, *step.CLICommand, result, i) 172 | 173 | case step.HTTPRequest != nil: 174 | result := runHTTPRequest(client, baseURL, variables, *step.HTTPRequest) 175 | results[i].HTTPRequestResult = &result 176 | sendHTTPRequestResults(ch, *step.HTTPRequest, result, i) 177 | 178 | default: 179 | cobra.CheckErr("unable to run lesson: missing step") 180 | } 181 | } 182 | return results 183 | } 184 | 185 | func sendCLICommandResults(ch chan tea.Msg, cmd api.CLIStepCLICommand, result api.CLICommandResult, index int) { 186 | for _, test := range cmd.Tests { 187 | ch <- messages.StartTestMsg{Text: prettyPrintCLICommand(test, result.Variables)} 188 | } 189 | 190 | for j := range cmd.Tests { 191 | ch <- messages.ResolveTestMsg{ 192 | StepIndex: index, 193 | TestIndex: j, 194 | } 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{ 212 | StepIndex: index, 213 | TestIndex: j, 214 | } 215 | } 216 | 217 | ch <- messages.ResolveStepMsg{ 218 | Index: index, 219 | Result: &api.CLIStepResult{ 220 | HTTPRequestResult: &result, 221 | }, 222 | } 223 | } 224 | 225 | func ApplySubmissionResults(cliData api.CLIData, failure *api.VerificationResultStructuredErrCLI, ch chan tea.Msg) { 226 | for i, step := range cliData.Steps { 227 | pass := true 228 | if failure != nil { 229 | pass = i < failure.FailedStepIndex 230 | } 231 | 232 | ch <- messages.ResolveStepMsg{ 233 | Index: i, 234 | Passed: &pass, 235 | } 236 | 237 | if step.CLICommand != nil { 238 | for j := range step.CLICommand.Tests { 239 | ch <- messages.ResolveTestMsg{ 240 | StepIndex: i, 241 | TestIndex: j, 242 | Passed: &pass, 243 | } 244 | } 245 | } 246 | if step.HTTPRequest != nil { 247 | for j := range step.HTTPRequest.Tests { 248 | ch <- messages.ResolveTestMsg{ 249 | StepIndex: i, 250 | TestIndex: j, 251 | Passed: &pass, 252 | } 253 | } 254 | } 255 | 256 | if !pass { 257 | break 258 | } 259 | 260 | } 261 | } 262 | 263 | func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string) string { 264 | if test.ExitCode != nil { 265 | return fmt.Sprintf("Expect exit code %d", *test.ExitCode) 266 | } 267 | if test.StdoutLinesGt != nil { 268 | return fmt.Sprintf("Expect > %d lines on stdout", *test.StdoutLinesGt) 269 | } 270 | if test.StdoutContainsAll != nil { 271 | str := "Expect stdout to contain all of:" 272 | for _, contains := range test.StdoutContainsAll { 273 | interpolatedContains := InterpolateVariables(contains, variables) 274 | str += fmt.Sprintf("\n - '%s'", interpolatedContains) 275 | } 276 | return str 277 | } 278 | if test.StdoutContainsNone != nil { 279 | str := "Expect stdout to contain none of:" 280 | for _, containsNone := range test.StdoutContainsNone { 281 | interpolatedContainsNone := InterpolateVariables(containsNone, variables) 282 | str += fmt.Sprintf("\n - '%s'", interpolatedContainsNone) 283 | } 284 | return str 285 | } 286 | return "" 287 | } 288 | 289 | func prettyPrintHTTPTest(test api.HTTPRequestTest, variables map[string]string) string { 290 | if test.StatusCode != nil { 291 | return fmt.Sprintf("Expecting status code: %d", *test.StatusCode) 292 | } 293 | if test.BodyContains != nil { 294 | interpolated := InterpolateVariables(*test.BodyContains, variables) 295 | return fmt.Sprintf("Expecting body to contain: %s", interpolated) 296 | } 297 | if test.BodyContainsNone != nil { 298 | interpolated := InterpolateVariables(*test.BodyContainsNone, variables) 299 | return fmt.Sprintf("Expecting JSON body to not contain: %s", interpolated) 300 | } 301 | if test.HeadersContain != nil { 302 | interpolatedKey := InterpolateVariables(test.HeadersContain.Key, variables) 303 | interpolatedValue := InterpolateVariables(test.HeadersContain.Value, variables) 304 | return fmt.Sprintf("Expecting headers to contain: '%s: %v'", interpolatedKey, interpolatedValue) 305 | } 306 | if test.TrailersContain != nil { 307 | interpolatedKey := InterpolateVariables(test.TrailersContain.Key, variables) 308 | interpolatedValue := InterpolateVariables(test.TrailersContain.Value, variables) 309 | return fmt.Sprintf("Expecting trailers to contain: '%s: %v'", interpolatedKey, interpolatedValue) 310 | } 311 | if test.JSONValue != nil { 312 | var val any 313 | switch { 314 | case test.JSONValue.IntValue != nil: 315 | val = *test.JSONValue.IntValue 316 | case test.JSONValue.StringValue != nil: 317 | val = *test.JSONValue.StringValue 318 | case test.JSONValue.BoolValue != nil: 319 | val = *test.JSONValue.BoolValue 320 | } 321 | 322 | var op string 323 | switch test.JSONValue.Operator { 324 | case api.OpEquals: 325 | op = "to be equal to" 326 | case api.OpGreaterThan: 327 | op = "to be greater than" 328 | case api.OpContains: 329 | op = "contains" 330 | case api.OpNotContains: 331 | op = "to not contain" 332 | } 333 | 334 | expecting := fmt.Sprintf("Expecting JSON at %v %s %v", test.JSONValue.Path, op, val) 335 | return InterpolateVariables(expecting, variables) 336 | } 337 | return "" 338 | } 339 | 340 | // truncateAndStringifyBody 341 | // in some lessons we yeet the entire body up to the server, but we really shouldn't ever care 342 | // about more than 100,000 stringified characters of it, so this protects against giant bodies 343 | func truncateAndStringifyBody(body []byte) string { 344 | bodyString := string(body) 345 | const maxBodyLength = 1000000 346 | if len(bodyString) > maxBodyLength { 347 | bodyString = bodyString[:maxBodyLength] 348 | } 349 | return bodyString 350 | } 351 | 352 | func parseVariables(body []byte, vardefs []api.HTTPRequestResponseVariable, variables map[string]string) error { 353 | for _, vardef := range vardefs { 354 | val, err := valFromJQPath(vardef.Path, string(body)) 355 | if err != nil { 356 | return err 357 | } 358 | variables[vardef.Name] = fmt.Sprintf("%v", val) 359 | } 360 | return nil 361 | } 362 | 363 | func valFromJQPath(path string, jsn string) (any, error) { 364 | vals, err := valsFromJQPath(path, jsn) 365 | if err != nil { 366 | return nil, err 367 | } 368 | if len(vals) != 1 { 369 | return nil, errors.New("invalid number of values found") 370 | } 371 | val := vals[0] 372 | if val == nil { 373 | return nil, errors.New("value not found") 374 | } 375 | return val, nil 376 | } 377 | 378 | func valsFromJQPath(path string, jsn string) ([]any, error) { 379 | var parseable any 380 | err := json.Unmarshal([]byte(jsn), &parseable) 381 | if err != nil { 382 | return nil, err 383 | } 384 | 385 | query, err := gojq.Parse(path) 386 | if err != nil { 387 | return nil, err 388 | } 389 | iter := query.Run(parseable) 390 | vals := []any{} 391 | for { 392 | v, ok := iter.Next() 393 | if !ok { 394 | break 395 | } 396 | if err, ok := v.(error); ok { 397 | if err, ok := err.(*gojq.HaltError); ok && err.Value() == nil { 398 | break 399 | } 400 | return nil, err 401 | } 402 | vals = append(vals, v) 403 | } 404 | return vals, nil 405 | } 406 | 407 | func InterpolateVariables(template string, vars map[string]string) string { 408 | r := regexp.MustCompile(`\$\{([^}]+)\}`) 409 | return r.ReplaceAllStringFunc(template, func(m string) string { 410 | // Extract the key from the match, which is in the form ${key} 411 | key := strings.TrimSuffix(strings.TrimPrefix(m, "${"), "}") 412 | if val, ok := vars[key]; ok { 413 | return val 414 | } 415 | return m // return the original placeholder if no substitution found 416 | }) 417 | } 418 | --------------------------------------------------------------------------------