From 1f055b2f5b1f0774673dbdf4271c2f35f1ab5c1f Mon Sep 17 00:00:00 2001 From: Valentin Kiselev Date: Tue, 23 Jul 2024 12:00:46 +0300 Subject: [PATCH] feat: add self-update command (#778) * feat: add auto updater * fix: query latest release * feat: implement the upgrader * fix: move upgrader to a specific folder * ci: adjust CI to not include upgrader in npm and rubygems packages * chore: add package description * chore: add colors * chore: simplify the look and feel of commands * chore: small cosmetic changes * chore: add backup recover on issues with file system * test: add tests * fix: special fix for windows --- .goreleaser.yml | 78 ++++++--- cmd/add.go | 4 +- cmd/commands.go | 23 +++ cmd/commands_no_self_update.go | 22 +++ cmd/dump.go | 4 +- cmd/install.go | 4 +- cmd/root.go | 11 +- cmd/run.go | 4 +- cmd/self_update.go | 73 ++++++++ cmd/uninstall.go | 4 +- cmd/version.go | 8 +- go.mod | 9 +- go.sum | 15 +- internal/lefthook/lefthook.go | 4 +- internal/updater/updater.go | 278 +++++++++++++++++++++++++++++++ internal/updater/updater_test.go | 162 ++++++++++++++++++ packaging/pack.rb | 48 +++--- 17 files changed, 680 insertions(+), 71 deletions(-) create mode 100644 cmd/commands.go create mode 100644 cmd/commands_no_self_update.go create mode 100644 cmd/self_update.go create mode 100644 internal/updater/updater.go create mode 100644 internal/updater/updater_test.go diff --git a/.goreleaser.yml b/.goreleaser.yml index 7136f89e..b611c018 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,29 +3,59 @@ before: hooks: - go generate ./... builds: -- env: - - CGO_ENABLED=0 - goos: - - linux - - darwin - - windows - - freebsd - goarch: - - amd64 - - arm64 - - 386 - ignore: - - goos: darwin - goarch: 386 - - goos: linux - goarch: 386 - - goos: freebsd - goarch: 386 - ldflags: - - -s -w -X github.com/evilmartians/lefthook/internal/version.commit={{.Commit}} + # Builds the binaries without `lefthook upgrade` + - id: no_self_update + tags: + - no_self_update + env: + - GCO_ENABLED=0 + goos: + - linux + - darwin + - windows + - freebsd + goarch: + - amd64 + - arm64 + - 386 + ignore: + - goos: darwin + goarch: 386 + - goos: linux + goarch: 386 + - goos: freebsd + goarch: 386 + ldflags: + - -s -w -X github.com/evilmartians/lefthook/internal/version.commit={{.Commit}} + + # Full lefthook binary + - id: lefthook + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + - freebsd + goarch: + - amd64 + - arm64 + - 386 + ignore: + - goos: darwin + goarch: 386 + - goos: linux + goarch: 386 + - goos: freebsd + goarch: 386 + ldflags: + - -s -w -X github.com/evilmartians/lefthook/internal/version.commit={{.Commit}} + archives: - id: lefthook format: binary + builds: + - lefthook files: - none* name_template: >- @@ -36,8 +66,11 @@ archives: {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} + - id: lefthook-gz format: gz + builds: + - lefthook files: - none* name_template: >- @@ -48,10 +81,13 @@ archives: {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} + checksum: name_template: '{{ .ProjectName }}_checksums.txt' + snapshot: name_template: "{{ .Tag }}" + changelog: sort: asc filters: @@ -78,7 +114,7 @@ nfpms: file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' homepage: https://github.com/evilmartians/lefthook description: Lefthook a single dependency-free binary to manage all your git hooks that works with any language in any environment, and in all common team workflows - maintainer: Alexander Abroskin + maintainer: Evil Martians license: MIT vendor: Evil Martians formats: diff --git a/cmd/add.go b/cmd/add.go index ad3f1f16..871727ba 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -12,7 +12,9 @@ import ( //go:embed add-doc.txt var addDoc string -func newAddCmd(opts *lefthook.Options) *cobra.Command { +type add struct{} + +func (add) New(opts *lefthook.Options) *cobra.Command { args := lefthook.AddArgs{} addHookCompletions := func(cmd *cobra.Command, args []string, toComplete string) (ret []string, compDir cobra.ShellCompDirective) { diff --git a/cmd/commands.go b/cmd/commands.go new file mode 100644 index 00000000..4871458f --- /dev/null +++ b/cmd/commands.go @@ -0,0 +1,23 @@ +//go:build !no_self_update + +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/evilmartians/lefthook/internal/lefthook" +) + +type command interface { + New(*lefthook.Options) *cobra.Command +} + +var commands = [...]command{ + version{}, + add{}, + install{}, + uninstall{}, + run{}, + dump{}, + selfUpdate{}, +} diff --git a/cmd/commands_no_self_update.go b/cmd/commands_no_self_update.go new file mode 100644 index 00000000..56daa0d5 --- /dev/null +++ b/cmd/commands_no_self_update.go @@ -0,0 +1,22 @@ +//go:build no_self_update + +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/evilmartians/lefthook/internal/lefthook" +) + +type command interface { + New(*lefthook.Options) *cobra.Command +} + +var commands = [...]command{ + version{}, + add{}, + install{}, + uninstall{}, + run{}, + dump{}, +} diff --git a/cmd/dump.go b/cmd/dump.go index e0071fc3..13d38744 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -6,7 +6,9 @@ import ( "github.com/evilmartians/lefthook/internal/lefthook" ) -func newDumpCmd(opts *lefthook.Options) *cobra.Command { +type dump struct{} + +func (dump) New(opts *lefthook.Options) *cobra.Command { dumpArgs := lefthook.DumpArgs{} dumpCmd := cobra.Command{ Use: "dump", diff --git a/cmd/install.go b/cmd/install.go index f27dbf5e..6d020212 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -7,7 +7,9 @@ import ( "github.com/evilmartians/lefthook/internal/log" ) -func newInstallCmd(opts *lefthook.Options) *cobra.Command { +type install struct{} + +func (install) New(opts *lefthook.Options) *cobra.Command { var a, force bool installCmd := cobra.Command{ diff --git a/cmd/root.go b/cmd/root.go index c3ecae63..2190990a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,15 +9,6 @@ import ( "github.com/evilmartians/lefthook/internal/log" ) -var commands = [...]func(*lefthook.Options) *cobra.Command{ - newVersionCmd, - newAddCmd, - newInstallCmd, - newUninstallCmd, - newRunCmd, - newDumpCmd, -} - func newRootCmd() *cobra.Command { options := lefthook.Options{ Fs: afero.NewOsFs(), @@ -61,7 +52,7 @@ func newRootCmd() *cobra.Command { } for _, subcommand := range commands { - rootCmd.AddCommand(subcommand(&options)) + rootCmd.AddCommand(subcommand.New(&options)) } return rootCmd diff --git a/cmd/run.go b/cmd/run.go index a07d2985..c7b48615 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -8,7 +8,9 @@ import ( "github.com/evilmartians/lefthook/internal/log" ) -func newRunCmd(opts *lefthook.Options) *cobra.Command { +type run struct{} + +func (run) New(opts *lefthook.Options) *cobra.Command { runArgs := lefthook.RunArgs{} runHookCompletions := func(cmd *cobra.Command, args []string, toComplete string) (ret []string, compDir cobra.ShellCompDirective) { diff --git a/cmd/self_update.go b/cmd/self_update.go new file mode 100644 index 00000000..21e48e81 --- /dev/null +++ b/cmd/self_update.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + + "github.com/evilmartians/lefthook/internal/lefthook" + "github.com/evilmartians/lefthook/internal/log" + "github.com/evilmartians/lefthook/internal/updater" +) + +type selfUpdate struct{} + +func (selfUpdate) New(opts *lefthook.Options) *cobra.Command { + var yes bool + upgradeCmd := cobra.Command{ + Use: "self-update", + Short: "Update lefthook executable", + Example: "lefthook self-update", + ValidArgsFunction: cobra.NoFileCompletions, + Args: cobra.NoArgs, + RunE: func(_cmd *cobra.Command, _args []string) error { + return update(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 update(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() + + // Handle interrupts + signalChan := make(chan os.Signal, 1) + signal.Notify( + signalChan, + syscall.SIGINT, + syscall.SIGTERM, + ) + go func() { + <-signalChan + cancel() + }() + + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to determine the binary path: %w", err) + } + + return updater.New().SelfUpdate(ctx, updater.Options{ + Yes: yes, + Force: opts.Force, + ExePath: exePath, + }) +} diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 7b3cdee7..470f85c5 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -6,7 +6,9 @@ import ( "github.com/evilmartians/lefthook/internal/lefthook" ) -func newUninstallCmd(opts *lefthook.Options) *cobra.Command { +type uninstall struct{} + +func (uninstall) New(opts *lefthook.Options) *cobra.Command { args := lefthook.UninstallArgs{} uninstallCmd := cobra.Command{ diff --git a/cmd/version.go b/cmd/version.go index b6c1a8e7..162ae0fa 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -5,10 +5,12 @@ import ( "github.com/evilmartians/lefthook/internal/lefthook" "github.com/evilmartians/lefthook/internal/log" - "github.com/evilmartians/lefthook/internal/version" + ver "github.com/evilmartians/lefthook/internal/version" ) -func newVersionCmd(_opts *lefthook.Options) *cobra.Command { +type version struct{} + +func (version) New(_opts *lefthook.Options) *cobra.Command { var verbose bool versionCmd := cobra.Command{ @@ -17,7 +19,7 @@ func newVersionCmd(_opts *lefthook.Options) *cobra.Command { ValidArgsFunction: cobra.NoFileCompletions, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { - log.Println(version.Version(verbose)) + log.Println(ver.Version(verbose)) }, } diff --git a/go.mod b/go.mod index fcaa777f..9005b16f 100644 --- a/go.mod +++ b/go.mod @@ -14,15 +14,20 @@ require ( github.com/mattn/go-tty v0.0.5 github.com/mitchellh/mapstructure v1.5.0 github.com/rogpeppe/go-internal v1.12.0 + github.com/schollz/progressbar/v3 v3.14.4 github.com/spf13/afero v1.11.0 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 gopkg.in/alessio/shellescape.v1 v1.0.0-20170105083845-52074bc9df61 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/x/ansi v0.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -49,8 +54,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..0f2a883b 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,8 @@ 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/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= @@ -106,10 +111,12 @@ golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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/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/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.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 } diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 00000000..c201bfa4 --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,278 @@ +// Package updater contains the self-update implementation for the lefthook executable. +package updater + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/schollz/progressbar/v3" + + "github.com/evilmartians/lefthook/internal/log" + "github.com/evilmartians/lefthook/internal/version" +) + +const ( + timeout = 10 * time.Second + latestReleaseURL = "https://api.github.com/repos/evilmartians/lefthook/releases/latest" + checksumsFilename = "lefthook_checksums.txt" + checksumFields = 2 + 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 sums differ, it's not safe to use the downloaded binary.\nIf you have problems upgrading lefthook please submit an issue to https://github.com/evilmartians/lefthook") + errUpdateFailed = errors.New("Update failed") + + 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 { + TagName string `json:"tag_name"` + Assets []asset +} + +type asset struct { + Name string `json:"name"` + DownloadURL string `json:"browser_download_url"` +} + +type Options struct { + Yes bool + Force bool + ExePath string +} + +type Updater struct { + client *http.Client + releaseURL string +} + +func New() *Updater { + return &Updater{ + client: &http.Client{Timeout: timeout}, + releaseURL: latestReleaseURL, + } +} + +func (u *Updater) SelfUpdate(ctx context.Context, opts Options) error { + rel, ferr := u.fetchLatestRelease(ctx) + if ferr != nil { + return fmt.Errorf("latest release fetch failed: %w", ferr) + } + + latestVersion := strings.TrimPrefix(rel.TagName, "v") + + if latestVersion == version.Version(false) && !opts.Force { + log.Infof("Up to date: %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 rel.Assets { + asset := rel.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 !opts.Yes { + log.Infof("Update %s to %s? %s ", log.Cyan("lefthook"), log.Yellow(latestVersion), log.Gray("[Y/n]")) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + ans := scanner.Text() + + if len(ans) > 0 && ans[0] != 'y' && ans[0] != 'Y' { + log.Debug("Update rejected") + return nil + } + } + + lefthookExePath := opts.ExePath + if realPath, serr := filepath.EvalSymlinks(lefthookExePath); serr == nil { + lefthookExePath = realPath + } + + destPath := lefthookExePath + "." + latestVersion + defer os.Remove(destPath) + + ok, err := u.download(ctx, wantedAsset, downloadURL, checksumURL, destPath) + if err != nil { + return err + } + if !ok { + return errInvalidHashsum + } + + backupPath := lefthookExePath + ".bak" + defer os.Remove(backupPath) + + log.Debugf("mv %s %s", lefthookExePath, backupPath) + if err = os.Rename(lefthookExePath, backupPath); err != nil { + return fmt.Errorf("failed to backup lefthook executable: %w", err) + } + + log.Debugf("mv %s %s", destPath, lefthookExePath) + err = os.Rename(destPath, lefthookExePath) + if err != nil { + log.Errorf("Failed to replace the lefthook executable: %s", err) + if err = os.Rename(backupPath, lefthookExePath); err != nil { + return fmt.Errorf("failed to recover from backup: %w", err) + } + + return errUpdateFailed + } + + log.Debugf("chmod +x %s", lefthookExePath) + if err = os.Chmod(lefthookExePath, modExecutable); err != nil { + log.Errorf("Failed to set executable file mode: %s", err) + if err = os.Rename(backupPath, lefthookExePath); err != nil { + return fmt.Errorf("failed to recover from backup: %w", err) + } + + return errUpdateFailed + } + + return nil +} + +func (u *Updater) fetchLatestRelease(ctx context.Context) (*release, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.releaseURL, nil) + if err != nil { + 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") + + resp, err := u.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + var rel release + if err = json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("failed to parse the Github response: %w", err) + } + + return &rel, nil +} + +func (u *Updater) download(ctx context.Context, name, fileURL, checksumURL, path string) (bool, error) { + log.Debugf("Downloading %s to %s", fileURL, path) + + filereq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) + if err != nil { + return false, fmt.Errorf("failed to build download request: %w", err) + } + + sumreq, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumURL, nil) + if err != nil { + return false, fmt.Errorf("failed to build checksum download request: %w", err) + } + + 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 := u.client.Do(filereq) + if err != nil { + return false, fmt.Errorf("download request failed: %w", err) + } + defer resp.Body.Close() + + checksumResp, err := u.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()) + if len(sums) < checksumFields { + continue + } + + 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", name, hashsum) + + return false, nil +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 00000000..0f3e78e2 --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,162 @@ +package updater + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/evilmartians/lefthook/internal/version" +) + +func TestUpdater_SelfUpdate(t *testing.T) { + var extension string + if runtime.GOOS == "windows" { + extension = ".exe" + } + exePath := filepath.Join(os.TempDir(), "lefthook") + for name, tt := range map[string]struct { + latestRelease string + assetName string + checksums string + opts Options + asset []byte + err error + }{ + "asset not found": { + latestRelease: "v1.0.0", + assetName: "lefthook_1.0.0_darwin_arm64", + opts: Options{ + Yes: true, + Force: false, + ExePath: exePath, + }, + err: errNoAsset, + }, + "no need to update": { + latestRelease: "v" + version.Version(false), + assetName: "lefthook_1.0.0_darwin_arm64", + opts: Options{ + Yes: true, + Force: false, + ExePath: exePath, + }, + err: nil, + }, + "forced update but asset not found": { + latestRelease: "v" + version.Version(false), + assetName: "lefthook_1.0.0_darwin_arm64", + opts: Options{ + Yes: true, + Force: true, + ExePath: exePath, + }, + err: errNoAsset, + }, + "invalid hashsum": { + latestRelease: "v1.0.0", + assetName: "lefthook_1.0.0_" + osNames[runtime.GOOS] + "_" + archNames[runtime.GOARCH] + extension, + opts: Options{ + Yes: true, + Force: true, + ExePath: exePath, + }, + asset: []byte{65, 54, 24, 32, 43, 67, 21}, + checksums: ` + 67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_MacOS_arm64 + 67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_MacOS_x86_64 + 67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_Linux_x86_64 + 67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_Linux_arm64 + 67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_Windows_x86_64.exe + `, + err: errInvalidHashsum, + }, + "success": { + latestRelease: "v1.0.0", + assetName: "lefthook_1.0.0_" + osNames[runtime.GOOS] + "_" + archNames[runtime.GOARCH] + extension, + opts: Options{ + Yes: true, + Force: true, + ExePath: exePath, + }, + asset: []byte{65, 54, 24, 32, 43, 67, 21}, + checksums: ` + 0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_MacOS_arm64 + 0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_MacOS_x86_64 + 0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_Linux_x86_64 + 0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_Linux_arm64 + 0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_Windows_x86_64.exe + `, + err: nil, + }, + } { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + file, err := os.Create(tt.opts.ExePath) + assert.NoError(err) + file.Close() + + checksumServer := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n, werr := w.Write([]byte(tt.checksums)) + assert.Equal(n, len(tt.checksums)) + assert.NoError(werr) + })) + defer checksumServer.Close() + assetServer := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n, werr := w.Write(tt.asset) + assert.Equal(n, len(tt.asset)) + assert.NoError(werr) + })) + defer assetServer.Close() + + releaseServer := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.NoError(json.NewEncoder(w).Encode(map[string]interface{}{ + "tag_name": tt.latestRelease, + "assets": []map[string]string{ + { + "name": tt.assetName, + "browser_download_url": assetServer.URL, + }, + { + "name": "lefthook_checksums.txt", + "browser_download_url": checksumServer.URL, + }, + }, + })) + })) + defer releaseServer.Close() + + upd := Updater{ + client: releaseServer.Client(), + releaseURL: releaseServer.URL, + } + + err = upd.SelfUpdate(context.Background(), tt.opts) + + if tt.err != nil { + if !errors.Is(err, tt.err) { + t.Error(err) + } + } else { + assert.NoError(err) + + if tt.asset != nil { + content, err := os.ReadFile(tt.opts.ExePath) + assert.NoError(err) + + assert.Equal(content, tt.asset) + } + } + }) + } +} diff --git a/packaging/pack.rb b/packaging/pack.rb index 2e20cdb9..bb9e9f52 100755 --- a/packaging/pack.rb +++ b/packaging/pack.rb @@ -54,42 +54,42 @@ def put_binaries cd(__dir__) puts "Putting binaries to packages..." { - "#{DIST}/lefthook_linux_amd64_v1/lefthook" => "npm/lefthook-linux-x64/bin/lefthook", - "#{DIST}/lefthook_linux_arm64/lefthook" => "npm/lefthook-linux-arm64/bin/lefthook", - "#{DIST}/lefthook_freebsd_amd64_v1/lefthook" => "npm/lefthook-freebsd-x64/bin/lefthook", - "#{DIST}/lefthook_freebsd_arm64/lefthook" => "npm/lefthook-freebsd-arm64/bin/lefthook", - "#{DIST}/lefthook_windows_amd64_v1/lefthook.exe" => "npm/lefthook-windows-x64/bin/lefthook.exe", - "#{DIST}/lefthook_windows_arm64/lefthook.exe" => "npm/lefthook-windows-arm64/bin/lefthook.exe", - "#{DIST}/lefthook_darwin_amd64_v1/lefthook" => "npm/lefthook-darwin-x64/bin/lefthook", - "#{DIST}/lefthook_darwin_arm64/lefthook" => "npm/lefthook-darwin-arm64/bin/lefthook", + "#{DIST}/no_self_update_linux_amd64_v1/lefthook" => "npm/lefthook-linux-x64/bin/lefthook", + "#{DIST}/no_self_update_linux_arm64/lefthook" => "npm/lefthook-linux-arm64/bin/lefthook", + "#{DIST}/no_self_update_freebsd_amd64_v1/lefthook" => "npm/lefthook-freebsd-x64/bin/lefthook", + "#{DIST}/no_self_update_freebsd_arm64/lefthook" => "npm/lefthook-freebsd-arm64/bin/lefthook", + "#{DIST}/no_self_update_windows_amd64_v1/lefthook.exe" => "npm/lefthook-windows-x64/bin/lefthook.exe", + "#{DIST}/no_self_update_windows_arm64/lefthook.exe" => "npm/lefthook-windows-arm64/bin/lefthook.exe", + "#{DIST}/no_self_update_darwin_amd64_v1/lefthook" => "npm/lefthook-darwin-x64/bin/lefthook", + "#{DIST}/no_self_update_darwin_arm64/lefthook" => "npm/lefthook-darwin-arm64/bin/lefthook", }.each do |(source, dest)| mkdir_p(File.dirname(dest)) cp(source, dest, verbose: true) end { - "#{DIST}/lefthook_linux_amd64_v1/lefthook" => "npm-bundled/bin/lefthook-linux-x64/lefthook", - "#{DIST}/lefthook_linux_arm64/lefthook" => "npm-bundled/bin/lefthook-linux-arm64/lefthook", - "#{DIST}/lefthook_freebsd_amd64_v1/lefthook" => "npm-bundled/bin/lefthook-freebsd-x64/lefthook", - "#{DIST}/lefthook_freebsd_arm64/lefthook" => "npm-bundled/bin/lefthook-freebsd-arm64/lefthook", - "#{DIST}/lefthook_windows_amd64_v1/lefthook.exe" => "npm-bundled/bin/lefthook-windows-x64/lefthook.exe", - "#{DIST}/lefthook_windows_arm64/lefthook.exe" => "npm-bundled/bin/lefthook-windows-arm64/lefthook.exe", - "#{DIST}/lefthook_darwin_amd64_v1/lefthook" => "npm-bundled/bin/lefthook-darwin-x64/lefthook", - "#{DIST}/lefthook_darwin_arm64/lefthook" => "npm-bundled/bin/lefthook-darwin-arm64/lefthook", + "#{DIST}/no_self_update_linux_amd64_v1/lefthook" => "npm-bundled/bin/lefthook-linux-x64/lefthook", + "#{DIST}/no_self_update_linux_arm64/lefthook" => "npm-bundled/bin/lefthook-linux-arm64/lefthook", + "#{DIST}/no_self_update_freebsd_amd64_v1/lefthook" => "npm-bundled/bin/lefthook-freebsd-x64/lefthook", + "#{DIST}/no_self_update_freebsd_arm64/lefthook" => "npm-bundled/bin/lefthook-freebsd-arm64/lefthook", + "#{DIST}/no_self_update_windows_amd64_v1/lefthook.exe" => "npm-bundled/bin/lefthook-windows-x64/lefthook.exe", + "#{DIST}/no_self_update_windows_arm64/lefthook.exe" => "npm-bundled/bin/lefthook-windows-arm64/lefthook.exe", + "#{DIST}/no_self_update_darwin_amd64_v1/lefthook" => "npm-bundled/bin/lefthook-darwin-x64/lefthook", + "#{DIST}/no_self_update_darwin_arm64/lefthook" => "npm-bundled/bin/lefthook-darwin-arm64/lefthook", }.each do |(source, dest)| mkdir_p(File.dirname(dest)) cp(source, dest, verbose: true) end { - "#{DIST}/lefthook_linux_amd64_v1/lefthook" => "rubygems/libexec/lefthook-linux-x64/lefthook", - "#{DIST}/lefthook_linux_arm64/lefthook" => "rubygems/libexec/lefthook-linux-arm64/lefthook", - "#{DIST}/lefthook_freebsd_amd64_v1/lefthook" => "rubygems/libexec/lefthook-freebsd-x64/lefthook", - "#{DIST}/lefthook_freebsd_arm64/lefthook" => "rubygems/libexec/lefthook-freebsd-arm64/lefthook", - "#{DIST}/lefthook_windows_amd64_v1/lefthook.exe" => "rubygems/libexec/lefthook-windows-x64/lefthook.exe", - "#{DIST}/lefthook_windows_arm64/lefthook.exe" => "rubygems/libexec/lefthook-windows-arm64/lefthook.exe", - "#{DIST}/lefthook_darwin_amd64_v1/lefthook" => "rubygems/libexec/lefthook-darwin-x64/lefthook", - "#{DIST}/lefthook_darwin_arm64/lefthook" => "rubygems/libexec/lefthook-darwin-arm64/lefthook", + "#{DIST}/no_self_update_linux_amd64_v1/lefthook" => "rubygems/libexec/lefthook-linux-x64/lefthook", + "#{DIST}/no_self_update_linux_arm64/lefthook" => "rubygems/libexec/lefthook-linux-arm64/lefthook", + "#{DIST}/no_self_update_freebsd_amd64_v1/lefthook" => "rubygems/libexec/lefthook-freebsd-x64/lefthook", + "#{DIST}/no_self_update_freebsd_arm64/lefthook" => "rubygems/libexec/lefthook-freebsd-arm64/lefthook", + "#{DIST}/no_self_update_windows_amd64_v1/lefthook.exe" => "rubygems/libexec/lefthook-windows-x64/lefthook.exe", + "#{DIST}/no_self_update_windows_arm64/lefthook.exe" => "rubygems/libexec/lefthook-windows-arm64/lefthook.exe", + "#{DIST}/no_self_update_darwin_amd64_v1/lefthook" => "rubygems/libexec/lefthook-darwin-x64/lefthook", + "#{DIST}/no_self_update_darwin_arm64/lefthook" => "rubygems/libexec/lefthook-darwin-arm64/lefthook", }.each do |(source, dest)| mkdir_p(File.dirname(dest)) cp(source, dest, verbose: true)