From cd2a660c561df8e96b927b141fce72ff1a693c20 Mon Sep 17 00:00:00 2001 From: Valentin Kiselev Date: Mon, 22 Jul 2024 17:43:50 +0300 Subject: [PATCH] feat: implement the upgrader --- cmd/upgrade.go | 256 +++++++++++++++++++++++++++++++--- go.mod | 6 +- go.sum | 13 ++ internal/lefthook/lefthook.go | 4 +- 4 files changed, 253 insertions(+), 26 deletions(-) diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 0094f0ed..3bc9413d 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -1,13 +1,24 @@ package cmd import ( + "bufio" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "fmt" + "io" "net/http" + "os" + "os/signal" + "path/filepath" + "runtime" "strings" + "syscall" "time" + "github.com/schollz/progressbar/v3" "github.com/spf13/cobra" "github.com/evilmartians/lefthook/internal/lefthook" @@ -16,8 +27,28 @@ import ( ) const ( - timeout = 10 * time.Second - latestReleaseURL = "https://api.github.com/repos/evilmartians/lefthook/releases/latest" + timeout = 10 * time.Second + latestReleaseURL = "https://api.github.com/repos/evilmartians/lefthook/releases/latest" + checksumsFilename = "lefthook_checksums.txt" + modExecutable os.FileMode = 0o755 +) + +var ( + errNoAsset = errors.New("Couldn't find an asset to download. Please submit an issue to https://github.com/evilmartians/lefthook") + errInvalidHashsum = errors.New("SHA256 sum differs") + + osNames = map[string]string{ + "windows": "Windows", + "darwin": "MacOS", + "linux": "Linux", + "freebsd": "Freebsd", + } + + archNames = map[string]string{ + "amd64": "x86_64", + "arm64": "arm64", + "386": "i386", + } ) type Release struct { @@ -30,7 +61,7 @@ type Asset struct { DownloadURL string `json:"browser_download_url"` } -func newUpgradeCmd(_ *lefthook.Options) *cobra.Command { +func newUpgradeCmd(opts *lefthook.Options) *cobra.Command { var yes bool upgradeCmd := cobra.Command{ Use: "upgrade", @@ -39,51 +70,232 @@ func newUpgradeCmd(_ *lefthook.Options) *cobra.Command { ValidArgsFunction: cobra.NoFileCompletions, Args: cobra.NoArgs, RunE: func(_cmd *cobra.Command, _args []string) error { - return upgrade(yes) + return upgrade(opts, yes) }, } upgradeCmd.Flags().BoolVarP(&yes, "yes", "y", false, "no prompt") + upgradeCmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "force upgrade") + upgradeCmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "show verbose logs") return &upgradeCmd } -func upgrade(ask bool) error { +func upgrade(opts *lefthook.Options, yes bool) error { + if os.Getenv(lefthook.EnvVerbose) == "1" || os.Getenv(lefthook.EnvVerbose) == "true" { + opts.Verbose = true + } + if opts.Verbose { + log.SetLevel(log.DebugLevel) + log.Debug("Verbose mode enabled") + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Determine the download URL - // curl -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" https://api.github.com/repos/evilmartians/lefthook/releases/latest + // Handle interrupts + signalChan := make(chan os.Signal, 1) + signal.Notify( + signalChan, + syscall.SIGINT, + syscall.SIGTERM, + ) + go func() { + <-signalChan + cancel() + }() + + client := &http.Client{ + Timeout: timeout, + } + release, ferr := fetchLatestRelease(ctx, client) + if ferr != nil { + return fmt.Errorf("latest release fetch failed: %w", ferr) + } + + latestVersion := strings.TrimPrefix(release.TagName, "v") + + if latestVersion == version.Version(false) && !opts.Force { + log.Infof("Already installed the latest version: %s\n", latestVersion) + return nil + } + + wantedAsset := fmt.Sprintf("lefthook_%s_%s_%s", latestVersion, osNames[runtime.GOOS], archNames[runtime.GOARCH]) + if runtime.GOOS == "windows" { + wantedAsset += ".exe" + } + + log.Debugf("Searching assets for %s", wantedAsset) + + var downloadURL string + var checksumURL string + for i := range release.Assets { + asset := release.Assets[i] + if len(downloadURL) == 0 && asset.Name == wantedAsset { + downloadURL = asset.DownloadURL + if len(checksumURL) > 0 { + break + } + } + + if len(checksumURL) == 0 && asset.Name == checksumsFilename { + checksumURL = asset.DownloadURL + if len(downloadURL) > 0 { + break + } + } + } + + if len(downloadURL) == 0 { + log.Warnf("Couldn't find the right asset to download. Wanted: %s\n", wantedAsset) + return errNoAsset + } + + if len(checksumURL) == 0 { + log.Warn("Couldn't find checksums") + } + + if !yes { + log.Infof("Do you want to upgrade lefthook to %s? [Y/n] ", latestVersion) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + ans := scanner.Text() + + if len(ans) > 0 && ans[0] != 'y' && ans[0] != 'Y' { + log.Debug("Upgrade rejected") + return nil + } + } + + lefthookExePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to determine the binary path: %w", err) + } + + if realPath, serr := filepath.EvalSymlinks(lefthookExePath); serr == nil { + lefthookExePath = realPath + } + + destPath := lefthookExePath + "." + latestVersion + defer os.Remove(destPath) + + log.Debugf("Downloading to %s", destPath) + ok, err := download(ctx, client, wantedAsset, downloadURL, checksumURL, destPath) + if err != nil { + return err + } + if !ok { + return errInvalidHashsum + } + + backupPath := lefthookExePath + ".bak" + defer os.Remove(backupPath) + + log.Debugf("Renaming %s -> %s", lefthookExePath, backupPath) + if err = os.Rename(lefthookExePath, backupPath); err != nil { + return fmt.Errorf("failed to backup lefthook executable: %w", err) + } + + log.Debugf("Renaming %s -> %s", destPath, lefthookExePath) + err = os.Rename(destPath, lefthookExePath) + if err != nil { + log.Errorf("Failed to replace the lefthook executable: %s\n", err) + if err = os.Rename(backupPath, lefthookExePath); err != nil { + return fmt.Errorf("failed to recover from backup: %w", err) + } + + return nil + } + + log.Debugf("Making file %s executable", lefthookExePath) + if err = os.Chmod(lefthookExePath, modExecutable); err != nil { + return fmt.Errorf("failed to set mod executable: %w", err) + } + + return nil +} + +func fetchLatestRelease(ctx context.Context, client *http.Client) (*Release, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil) if err != nil { - return fmt.Errorf("failed to initialize a request: %w", err) + return nil, fmt.Errorf("failed to initialize a request: %w", err) } req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - client := &http.Client{ - Timeout: timeout, - } resp, err := client.Do(req) if err != nil { - return fmt.Errorf("request failed: %w", err) + return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() var release Release - json.NewDecoder(resp.Body).Decode(&release) + if err = json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("failed to parse the Github response: %w", err) + } - latestVersion := strings.TrimPrefix(release.TagName, "v") + return &release, nil +} - if latestVersion == version.Version(false) { - log.Infof("Already installed the latest version: %s", latestVersion) - return nil +func download(ctx context.Context, client *http.Client, name, fileURL, checksumURL, path string) (bool, error) { + filereq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) + if err != nil { + return false, fmt.Errorf("failed to build download request: %w", err) } - // Download the file and the hashsums - // .assets[N].browser_download_url - // Check hashsums + sumreq, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumURL, nil) + if err != nil { + return false, fmt.Errorf("failed to build checksum download request: %w", err) + } - // If ok - replace the binary - return nil + file, err := os.Create(path) + if err != nil { + return false, fmt.Errorf("failed to create destination path (%s): %w", path, err) + } + defer file.Close() + + resp, err := client.Do(filereq) + if err != nil { + return false, fmt.Errorf("download request failed: %w", err) + } + defer resp.Body.Close() + + checksumResp, err := client.Do(sumreq) + if err != nil { + return false, fmt.Errorf("checksum download request failed: %w", err) + } + defer checksumResp.Body.Close() + + bar := progressbar.DefaultBytes(resp.ContentLength+checksumResp.ContentLength, name) + + fileHasher := sha256.New() + if _, err = io.Copy(io.MultiWriter(file, fileHasher, bar), resp.Body); err != nil { + return false, fmt.Errorf("failed to download the file: %w", err) + } + log.Debug() + + hashsum := hex.EncodeToString(fileHasher.Sum(nil)) + + scanner := bufio.NewScanner(checksumResp.Body) + for scanner.Scan() { + sums := strings.Fields(scanner.Text()) + log.Debugf("Checking %s %s", sums[0], sums[1]) + if sums[1] == name { + if sums[0] == hashsum { + if err = bar.Finish(); err != nil { + log.Debugf("Progressbar error: %s", err) + } + + log.Debugf("Match %s %s", sums[0], sums[1]) + + return true, nil + } else { + return false, nil + } + } + } + + log.Debugf("No matches found for %s %s\n", name, hashsum) + + return false, nil } diff --git a/go.mod b/go.mod index fcaa777f..4867fb4d 100644 --- a/go.mod +++ b/go.mod @@ -23,8 +23,10 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/x/ansi v0.1.1 // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/schollz/progressbar/v3 v3.14.4 // indirect github.com/sourcegraph/conc v0.3.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect @@ -49,8 +51,8 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect - golang.org/x/sys v0.19.0 // indirect - golang.org/x/term v0.15.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/term v0.22.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 845f8eac..efa9b7f3 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,7 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -52,6 +53,8 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-tty v0.0.5 h1:s09uXI7yDbXzzTTfw3zonKFzwGkyYlgU3OMjqA0ddz4= github.com/mattn/go-tty v0.0.5/go.mod h1:u5GGXBtZU6RQoKV8gY5W6UhMudbR5vXnUe7j3pxse28= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= @@ -71,6 +74,10 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/schollz/progressbar v1.0.0 h1:gbyFReLHDkZo8mxy/dLWMr+Mpb1MokGJ1FqCiqacjZM= +github.com/schollz/progressbar v1.0.0/go.mod h1:/l9I7PC3L3erOuz54ghIRKUEFcosiWfLvJv+Eq26UMs= +github.com/schollz/progressbar/v3 v3.14.4 h1:W9ZrDSJk7eqmQhd3uxFNNcTr0QL+xuGNI9dEMrw0r74= +github.com/schollz/progressbar/v3 v3.14.4/go.mod h1:aT3UQ7yGm+2ZjeXPqsjTenwL3ddUiuZ0kfQ/2tHlyNI= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -108,8 +115,14 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= diff --git a/internal/lefthook/lefthook.go b/internal/lefthook/lefthook.go index 6c81317e..b2c8e00c 100644 --- a/internal/lefthook/lefthook.go +++ b/internal/lefthook/lefthook.go @@ -16,8 +16,8 @@ import ( ) const ( + EnvVerbose = "LEFTHOOK_VERBOSE" // keep all output hookFileMode = 0o755 - envVerbose = "LEFTHOOK_VERBOSE" // keep all output oldHookPostfix = ".old" ) @@ -41,7 +41,7 @@ type Lefthook struct { // New returns an instance of Lefthook. func initialize(opts *Options) (*Lefthook, error) { - if os.Getenv(envVerbose) == "1" || os.Getenv(envVerbose) == "true" { + if os.Getenv(EnvVerbose) == "1" || os.Getenv(EnvVerbose) == "true" { opts.Verbose = true }