diff --git a/cli/cmd/turbo/main.go b/cli/cmd/turbo/main.go index 47922b379eeea..09be57d3fb6bc 100644 --- a/cli/cmd/turbo/main.go +++ b/cli/cmd/turbo/main.go @@ -1,7 +1,9 @@ package main import ( + "context" "fmt" + "log" "os" "runtime/debug" "strings" @@ -12,6 +14,7 @@ import ( prune "turbo/internal/prune" "turbo/internal/run" uiPkg "turbo/internal/ui" + "turbo/internal/update" "turbo/internal/util" "github.com/fatih/color" @@ -63,6 +66,7 @@ func main() { ui.Error(fmt.Sprintf("%s %s", uiPkg.ERROR_PREFIX, color.RedString(err.Error()))) os.Exit(1) } + c.HiddenCommands = []string{"graph"} c.Commands = map[string]cli.CommandFactory{ "run": func() (cli.Command, error) { @@ -164,5 +168,8 @@ func main() { } } }() + if err := update.CheckVersion(context.Background(), cf, turboVersion); err != nil { + log.Fatal(err) + } os.Exit(exitCode) } diff --git a/cli/go.mod b/cli/go.mod index 34ff75c130d28..9a41d928de828 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -15,8 +15,10 @@ require ( github.com/google/go-cmp v0.5.5 // indirect github.com/google/uuid v1.2.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 github.com/hashicorp/go-hclog v0.16.2 github.com/hashicorp/go-retryablehttp v0.6.8 + github.com/hashicorp/go-version v1.3.0 github.com/karrick/godirwalk v1.16.1 github.com/kelseyhightower/envconfig v1.4.0 github.com/kr/text v0.2.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index 7d793cfd72e47..183182b27d085 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -41,8 +41,9 @@ github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-hclog v0.16.2 h1:K4ev2ib4LdQETX5cSZBG0DVLk1jwGqSPXBjdah3veNs= github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= @@ -51,6 +52,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.6.8 h1:92lWxgpa+fF3FozM4B3UZtHZMJX8T5XT+TFdCxsPyWs= github.com/hashicorp/go-retryablehttp v0.6.8/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= +github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw= +github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ= github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= diff --git a/cli/internal/api/types.go b/cli/internal/api/types.go index c79addb68e925..365cd9135fa29 100644 --- a/cli/internal/api/types.go +++ b/cli/internal/api/types.go @@ -34,4 +34,8 @@ type LanguageBackend struct { GetWorkspaceGlobs func() ([]string, error) // Returns run command GetRunCommand func() []string + // GetInstallCommand returns the install command + GetInstallCommand func() []string + // GetTurboInstallCommand returns the turbo install command + GetTurboInstallCommand func() []string } diff --git a/cli/internal/backends/nodejs/nodejs.go b/cli/internal/backends/nodejs/nodejs.go index 20c8cc35e6fe2..6d64fdbe161be 100644 --- a/cli/internal/backends/nodejs/nodejs.go +++ b/cli/internal/backends/nodejs/nodejs.go @@ -31,6 +31,9 @@ var NodejsYarnBackend = api.LanguageBackend{ GetRunCommand: func() []string { return []string{"yarn", "run"} }, + GetTurboInstallCommand: func() []string { + return []string{"yarn", "add", "turbo", "--dev", "-w"} + }, } // PnpmWorkspaces is a representation of workspace package globs found @@ -61,6 +64,12 @@ var NodejsPnpmBackend = api.LanguageBackend{ GetRunCommand: func() []string { return []string{"pnpm", "run"} }, + GetInstallCommand: func() []string { + return []string{"pnpm", "install"} + }, + GetTurboInstallCommand: func() []string { + return []string{"pnpm", "install", "turbo", "-D", "-w"} + }, } var NodejsNpmBackend = api.LanguageBackend{ @@ -81,4 +90,7 @@ var NodejsNpmBackend = api.LanguageBackend{ GetRunCommand: func() []string { return []string{"npm", "run"} }, + GetTurboInstallCommand: func() []string { + return []string{"npm", "install", "turbo", "-D", "-w"} + }, } diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index c62c6145e5886..d01923a03ff3b 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -86,7 +86,7 @@ func ParseAndValidate(args []string, ui cli.Ui, turboVersion string) (c *Config, if err != nil { // not logged in } - partialConfig, err := ReadConfigFile(filepath.Join(".turbo", "config.json")) + partialConfig, err := ReadTurboConfigFile(filepath.Join(".turbo", "config.json")) if err != nil { // not linked } diff --git a/cli/internal/config/config_file.go b/cli/internal/config/config_file.go index f4c513827bed6..e08c79cb3eaf0 100644 --- a/cli/internal/config/config_file.go +++ b/cli/internal/config/config_file.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "fmt" "io/ioutil" "path/filepath" @@ -23,7 +24,7 @@ type TurborepoConfig struct { } // WriteUserConfigFile writes config file at a oath -func WriteConfigFile(path string, config *TurborepoConfig) error { +func WriteTurboConfigFile(path string, config *TurborepoConfig) error { jsonBytes, marhsallError := json.Marshal(config) if marhsallError != nil { return marhsallError @@ -37,15 +38,15 @@ func WriteConfigFile(path string, config *TurborepoConfig) error { // WriteUserConfigFile writes a user config file func WriteUserConfigFile(config *TurborepoConfig) error { - path, err := xdg.ConfigFile(filepath.Join("turborepo", "config.json")) + path, err := GetConfigFilePath("config.json") if err != nil { return err } - return WriteConfigFile(path, config) + return WriteTurboConfigFile(path, config) } -// ReadConfigFile reads a config file at a path -func ReadConfigFile(path string) (*TurborepoConfig, error) { +// ReadTurboConfigFile reads a config file at a path +func ReadTurboConfigFile(path string) (*TurborepoConfig, error) { var config = &TurborepoConfig{ Token: "", TeamId: "", @@ -66,7 +67,7 @@ func ReadConfigFile(path string) (*TurborepoConfig, error) { // ReadUserConfigFile reads a user config file func ReadUserConfigFile() (*TurborepoConfig, error) { - path, err := xdg.ConfigFile(filepath.Join("turborepo", "config.json")) + path, err := GetConfigFilePath("config.json") if err != nil { return &TurborepoConfig{ Token: "", @@ -76,10 +77,20 @@ func ReadUserConfigFile() (*TurborepoConfig, error) { TeamSlug: "", }, err } - return ReadConfigFile(path) + return ReadTurboConfigFile(path) } // DeleteUserConfigFile deletes a user config file func DeleteUserConfigFile() error { return WriteUserConfigFile(&TurborepoConfig{}) } + +// GetConfigFilePath is the path to the config file on the machine +func GetConfigFilePath(name string) (string, error) { + file, err := xdg.ConfigFile(filepath.Join("turborepo", name)) + if err != nil { + return "", fmt.Errorf("cannot get configuration file %q: %s", name, err) + } + + return file, nil +} diff --git a/cli/internal/login/link.go b/cli/internal/login/link.go index 4f6162f5aca79..298cc4129ad75 100644 --- a/cli/internal/login/link.go +++ b/cli/internal/login/link.go @@ -142,7 +142,7 @@ func (c *LinkCommand) Run(args []string) int { } } fs.EnsureDir(filepath.Join(".turbo", "config.json")) - fsErr := config.WriteConfigFile(filepath.Join(".turbo", "config.json"), &config.TurborepoConfig{ + fsErr := config.WriteTurboConfigFile(filepath.Join(".turbo", "config.json"), &config.TurborepoConfig{ TeamId: chosenTeam.ID, ApiUrl: c.Config.ApiUrl, }) diff --git a/cli/internal/login/unlink.go b/cli/internal/login/unlink.go index e44764b5f39a6..d83ea5fb6ab03 100644 --- a/cli/internal/login/unlink.go +++ b/cli/internal/login/unlink.go @@ -36,7 +36,7 @@ Usage: turbo unlink // Run executes tasks in the monorepo func (c *UnlinkCommand) Run(args []string) int { - if err := config.WriteConfigFile(filepath.Join(".turbo", "config.json"), &config.TurborepoConfig{}); err != nil { + if err := config.WriteTurboConfigFile(filepath.Join(".turbo", "config.json"), &config.TurborepoConfig{}); err != nil { c.logError(c.Config.Logger, "", fmt.Errorf("Could not unlink. Something went wrong: %w", err)) return 1 } diff --git a/cli/internal/update/update.go b/cli/internal/update/update.go new file mode 100644 index 0000000000000..93a532d0027fc --- /dev/null +++ b/cli/internal/update/update.go @@ -0,0 +1,235 @@ +// Package update is checking for a new version of Turborepo and informs the user +// to update. Most of the logic is copied from planetscale/cli: +// https://github.com/planetscale/cli/blob/main/internal/update/update.go +// and updated to our own needs. +package update + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "strings" + "time" + "turbo/internal/backends" + "turbo/internal/config" + "turbo/internal/util" + "unicode/utf8" + + cleanhttp "github.com/hashicorp/go-cleanhttp" + + "github.com/hashicorp/go-version" + "gopkg.in/yaml.v2" +) + +const RELEASE_URL = "https://api.github.com/repos/vercel/turborepo/releases/latest" + +type UpdateInfo struct { + Update bool + Reason string + ReleaseInfo *ReleaseInfo +} + +// ReleaseInfo stores information about a release +type ReleaseInfo struct { + Version string `json:"tag_name"` + URL string `json:"html_url"` + PublishedAt time.Time `json:"published_at"` +} + +// StateEntry stores the information we have checked for a new version. It's +// used to decide whether to check for a new version or not. +type StateEntry struct { + CheckedForUpdateAt time.Time `yaml:"checked-for-update-at"` + LatestRelease ReleaseInfo `yaml:"latest-release"` +} + +// CheckVersion checks for the given build version whether there is a new +// version of the CLI or not. +func CheckVersion(ctx context.Context, config *config.Config, buildVersion string) error { + if ctx.Err() != nil { + return ctx.Err() + } + + path, err := stateFilePath() + if err != nil { + return err + } + + updateInfo, err := checkVersion( + ctx, + buildVersion, + path, + latestVersion, + ) + if err != nil { + config.Logger.Debug("No update available", "latest version", updateInfo.ReleaseInfo.Version) + return nil + } + + if !updateInfo.Update { + config.Logger.Debug("No update available, latest version is currently installed", "version", buildVersion) + return nil + } + + backend, err := backends.GetBackend() + if err != nil { + return fmt.Errorf("cannot infer language backend and package manager: %w", err) + } + installCmdStr := strings.Join(backend.GetTurboInstallCommand(), " ") + installCommandLen := utf8.RuneCountInString((installCmdStr)) + util.Printf("${YELLOW}+----------------------------------------------------------------+${RESET}\n") + util.Printf("${YELLOW}|${RESET} ${YELLOW}|${RESET}\n") + util.Printf("${YELLOW}|${RESET} Update available for turbo: ${GREY}%s${RESET} → ${CYAN}%s${RESET}%s${YELLOW}|${RESET}\n", fmt.Sprintf("v%s", buildVersion), updateInfo.ReleaseInfo.Version, strings.Repeat(" ", 14-len(updateInfo.ReleaseInfo.Version))) + util.Printf("${YELLOW}|${RESET} Run ${CYAN}%s${RESET} to update%s${YELLOW}|${RESET}\n", installCmdStr, strings.Repeat(" ", 46-installCommandLen)) + util.Printf("${YELLOW}|${RESET} ${YELLOW}|${RESET}\n") + util.Printf("${YELLOW}+----------------------------------------------------------------+${RESET}\n") + util.Printf("\n") + util.Printf("${GREY}For more information and release notes, visit:${RESET}\n") + util.Printf("${GREY}${UNDERLINE}%s${RESET}\n", updateInfo.ReleaseInfo.URL) + util.Printf("\n") + return nil +} + +func checkVersion( + ctx context.Context, + buildVersion, path string, + latestVersionFn func(ctx context.Context, addr string) (*ReleaseInfo, error), +) (*UpdateInfo, error) { + if _, exists := os.LookupEnv("TURBO_NO_UPDATE_NOTIFIER"); exists { + return &UpdateInfo{ + Update: false, + Reason: "TURBO_NO_UPDATE_NOTIFIER is set", + }, nil + } + + stateEntry, _ := getStateEntry(path) + if stateEntry != nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 1 { + return &UpdateInfo{ + Update: false, + Reason: "Latest version was already checked", + }, nil + } + + addr := RELEASE_URL + info, err := latestVersionFn(ctx, addr) + if err != nil { + return nil, err + } + + err = setStateEntry(path, time.Now(), *info) + if err != nil { + return nil, err + } + + v1, err := version.NewVersion(info.Version) + if err != nil { + return nil, err + } + + v2, err := version.NewVersion(buildVersion) + if err != nil { + return nil, err + } + + if v1.LessThanOrEqual(v2) { + return &UpdateInfo{ + Update: false, + Reason: fmt.Sprintf("Latest version (%s) is less than or equal to current build version (%s)", + info.Version, buildVersion), + ReleaseInfo: info, + }, nil + } + + return &UpdateInfo{ + Update: true, + Reason: fmt.Sprintf("Latest version (%s) is greater than the current build version (%s)", + info.Version, buildVersion), + ReleaseInfo: info, + }, nil + +} + +func latestVersion(ctx context.Context, addr string) (*ReleaseInfo, error) { + cleanClient := cleanhttp.DefaultClient() + req, err := http.NewRequestWithContext(ctx, "GET", addr, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "application/vnd.github.v3+json") + + getToken := func() string { + if t := os.Getenv("GH_TOKEN"); t != "" { + return t + } + return os.Getenv("GITHUB_TOKEN") + } + + if token := getToken(); token != "" { + req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) + } + + cleanClient.Timeout = time.Second * 15 + + resp, err := cleanClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + out, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !success { + return nil, fmt.Errorf("error fetching latest release: %v", string(out)) + } + + var info *ReleaseInfo + err = json.Unmarshal(out, &info) + if err != nil { + return nil, err + } + + return info, nil +} + +func getStateEntry(stateFilePath string) (*StateEntry, error) { + content, err := ioutil.ReadFile(stateFilePath) + if err != nil { + return nil, err + } + + var stateEntry StateEntry + err = yaml.Unmarshal(content, &stateEntry) + if err != nil { + return nil, err + } + + return &stateEntry, nil +} + +func setStateEntry(stateFilePath string, t time.Time, r ReleaseInfo) error { + data := StateEntry{ + CheckedForUpdateAt: t, + LatestRelease: r, + } + + content, err := yaml.Marshal(data) + if err != nil { + return err + } + _ = ioutil.WriteFile(stateFilePath, content, 0600) + + return nil +} + +func stateFilePath() (string, error) { + return config.GetConfigFilePath("state.yml") +} diff --git a/cli/internal/update/update_test.go b/cli/internal/update/update_test.go new file mode 100644 index 0000000000000..1d728c680d146 --- /dev/null +++ b/cli/internal/update/update_test.go @@ -0,0 +1,121 @@ +package update + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLatestVersion(t *testing.T) { + + var tests = []struct { + name string + resp *ReleaseInfo + statusCode int + }{ + { + name: "valid response", + statusCode: 200, + resp: &ReleaseInfo{ + Version: "v0.1.0", + }, + }, + { + name: "non valid response", + statusCode: 400, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + _ = json.NewEncoder(w).Encode(tt.resp) + })) + defer ts.Close() + + info, err := latestVersion(context.Background(), ts.URL) + + success := tt.statusCode >= 200 && tt.statusCode < 300 + if !success { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + assert.EqualValues(t, tt.resp, info) + + } + + }) + } + +} + +func TestCheckVersion(t *testing.T) { + + var tests = []struct { + name string + buildVersion string + latestVersion string + update bool + lastChecked time.Time + }{ + { + name: "new version", + buildVersion: "v0.1.0", + latestVersion: "v0.2.0", + update: true, + }, + { + name: "same version", + buildVersion: "v0.2.0", + latestVersion: "v0.2.0", + update: false, + }, + { + name: "higher version", + buildVersion: "v0.3.0", + latestVersion: "v0.2.0", + update: false, + }, + { + name: "new version, but we already checked in the past 24 hours", + buildVersion: "v0.1.0", + latestVersion: "v0.2.0", + update: false, + lastChecked: time.Now().Add(-time.Hour), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + + dir := t.TempDir() + path := filepath.Join(dir, "state.yml") + + if !tt.lastChecked.IsZero() { + err := setStateEntry(path, tt.lastChecked, ReleaseInfo{Version: tt.latestVersion}) + assert.Nil(t, err) + } + + updateInfo, err := checkVersion( + context.Background(), + tt.buildVersion, + path, + func(ctx context.Context, addr string) (*ReleaseInfo, error) { + return &ReleaseInfo{Version: tt.latestVersion}, nil + }, + ) + + assert.Nil(t, err) + assert.EqualValues(t, tt.update, updateInfo.Update) + + }) + } + +}