Skip to content

Commit

Permalink
feat: implement the upgrader
Browse files Browse the repository at this point in the history
  • Loading branch information
mrexox committed Jul 22, 2024
1 parent e60e847 commit cd2a660
Show file tree
Hide file tree
Showing 4 changed files with 253 additions and 26 deletions.
256 changes: 234 additions & 22 deletions cmd/upgrade.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 {
Expand All @@ -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",
Expand All @@ -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
}
6 changes: 4 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
Loading

0 comments on commit cd2a660

Please sign in to comment.