mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
306 lines
8.9 KiB
Go
306 lines
8.9 KiB
Go
// Package update keeps an installed CLI current.
|
|
//
|
|
// Two jobs: telling someone a newer release exists without getting in their
|
|
// way, and replacing the binary when they ask for it.
|
|
//
|
|
// The version lookup deliberately does not use the GitHub API. The
|
|
// unauthenticated API is rate limited per IP, which on a shared CI runner or
|
|
// behind a corporate NAT means the check fails for everyone at once; the
|
|
// releases/latest redirect is a plain HTTP redirect with no such limit.
|
|
package update
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
repo = "warmbly/warmbly"
|
|
// LatestURL redirects to the newest release's tag page.
|
|
LatestURL = "https://github.com/" + repo + "/releases/latest"
|
|
// DownloadBase is where the release assets live. Names carry no version,
|
|
// so "latest" resolves without knowing the tag first.
|
|
DownloadBase = "https://github.com/" + repo + "/releases/latest/download"
|
|
)
|
|
|
|
// CheckInterval is how often the background nudge looks for a new release.
|
|
// Once a day: often enough to matter, rare enough that nobody notices it.
|
|
const CheckInterval = 24 * time.Hour
|
|
|
|
// LatestVersion resolves the newest published release tag by following the
|
|
// latest-release redirect and reading the tag out of the final URL.
|
|
func LatestVersion(ctx context.Context, timeout time.Duration) (string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
client := &http.Client{
|
|
// Stop at the redirect: the tag is in the Location header, and
|
|
// following it would download an HTML page for nothing.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodHead, LatestURL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
location := resp.Header.Get("Location")
|
|
if location == "" {
|
|
return "", errors.New("no release redirect")
|
|
}
|
|
tag := location[strings.LastIndex(location, "/")+1:]
|
|
if !strings.HasPrefix(tag, "v") {
|
|
return "", fmt.Errorf("unexpected release tag %q", tag)
|
|
}
|
|
return tag, nil
|
|
}
|
|
|
|
// IsNewer reports whether candidate is a later release than current. Both are
|
|
// vX.Y.Z. A current version that is not a clean release tag (a dev build, a
|
|
// git describe string) returns false: someone running their own build does not
|
|
// want to be told to download ours.
|
|
func IsNewer(current, candidate string) bool {
|
|
cur, ok := parseVersion(current)
|
|
if !ok {
|
|
return false
|
|
}
|
|
next, ok := parseVersion(candidate)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
if next[i] != cur[i] {
|
|
return next[i] > cur[i]
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseVersion(v string) ([3]int, bool) {
|
|
var out [3]int
|
|
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
|
|
// A git describe string (1.2.3-4-gabc1234) or a prerelease is not a plain
|
|
// release, so it never compares.
|
|
if v == "" || strings.ContainsAny(v, "-+ ") {
|
|
return out, false
|
|
}
|
|
parts := strings.Split(v, ".")
|
|
if len(parts) != 3 {
|
|
return out, false
|
|
}
|
|
for i, p := range parts {
|
|
n, err := strconv.Atoi(p)
|
|
if err != nil || n < 0 {
|
|
return out, false
|
|
}
|
|
out[i] = n
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// Method is how this binary got here, which decides how it should be replaced.
|
|
type Method int
|
|
|
|
const (
|
|
// MethodBinary is a plain binary we can overwrite ourselves.
|
|
MethodBinary Method = iota
|
|
MethodHomebrew
|
|
MethodScoop
|
|
MethodGoInstall
|
|
MethodPackage
|
|
)
|
|
|
|
// UpgradeCommand is what to tell the user to run when we must not replace the
|
|
// binary ourselves. Empty when a self-replace is the right answer.
|
|
func (m Method) UpgradeCommand() string {
|
|
switch m {
|
|
case MethodHomebrew:
|
|
return "brew upgrade warmbly"
|
|
case MethodScoop:
|
|
return "scoop update warmbly"
|
|
case MethodGoInstall:
|
|
return "go install github.com/" + repo + "/cmd/cli@latest"
|
|
case MethodPackage:
|
|
return "your package manager"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// DetectMethod works out how this binary was installed from where it sits.
|
|
// Fighting a package manager by overwriting the file it owns produces a
|
|
// version that reverts on the next upgrade, so this is what stops that.
|
|
func DetectMethod(executable string) Method {
|
|
path, err := filepath.EvalSymlinks(executable)
|
|
if err != nil {
|
|
path = executable
|
|
}
|
|
// Backslashes are normalised explicitly rather than with filepath.ToSlash,
|
|
// which is a no-op off Windows: the detection then behaves the same
|
|
// wherever it runs, including in a test.
|
|
lower := strings.ToLower(strings.ReplaceAll(path, `\`, "/"))
|
|
|
|
switch {
|
|
case strings.Contains(lower, "/cellar/"), strings.Contains(lower, "/homebrew/"),
|
|
strings.Contains(lower, "/linuxbrew/"):
|
|
return MethodHomebrew
|
|
case strings.Contains(lower, "/scoop/"):
|
|
return MethodScoop
|
|
case strings.Contains(lower, "/go/bin/"), strings.HasSuffix(lower, "/gopath/bin/warmbly"):
|
|
return MethodGoInstall
|
|
case strings.HasPrefix(lower, "/usr/bin/"), strings.HasPrefix(lower, "/opt/"),
|
|
strings.HasPrefix(lower, "/snap/"), strings.HasPrefix(lower, "/nix/"):
|
|
return MethodPackage
|
|
default:
|
|
return MethodBinary
|
|
}
|
|
}
|
|
|
|
// AssetName is the archive published for the running platform.
|
|
func AssetName() string {
|
|
if runtime.GOOS == "windows" {
|
|
return fmt.Sprintf("warmbly_%s_%s.zip", runtime.GOOS, runtime.GOARCH)
|
|
}
|
|
return fmt.Sprintf("warmbly_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH)
|
|
}
|
|
|
|
// Replace downloads the newest build for this platform, verifies it against
|
|
// the published checksums, and swaps it in for the running binary.
|
|
//
|
|
// The swap is a rename, which is atomic: an interrupted upgrade leaves either
|
|
// the old binary or the new one, never half of either.
|
|
func Replace(ctx context.Context, executable string, progress func(string)) error {
|
|
if runtime.GOOS == "windows" {
|
|
return errors.New("self-upgrade is not supported on Windows because a running .exe cannot be replaced.\nRun the installer again instead:\n irm https://warmbly.com/cli.ps1 | iex")
|
|
}
|
|
|
|
asset := AssetName()
|
|
progress("downloading " + asset)
|
|
archive, err := download(ctx, DownloadBase+"/"+asset)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
progress("verifying checksum")
|
|
sums, err := download(ctx, DownloadBase+"/checksums.txt")
|
|
if err != nil {
|
|
return fmt.Errorf("could not fetch checksums.txt, so the download was not verified: %w", err)
|
|
}
|
|
want := checksumFor(string(sums), asset)
|
|
if want == "" {
|
|
return fmt.Errorf("checksums.txt has no entry for %s", asset)
|
|
}
|
|
sum := sha256.Sum256(archive)
|
|
if got := hex.EncodeToString(sum[:]); got != want {
|
|
return fmt.Errorf("checksum mismatch for %s.\n expected %s\n got %s\nNothing was changed", asset, want, got)
|
|
}
|
|
|
|
progress("unpacking")
|
|
binary, err := extractBinary(archive)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
target, err := filepath.EvalSymlinks(executable)
|
|
if err != nil {
|
|
target = executable
|
|
}
|
|
dir := filepath.Dir(target)
|
|
tmp, err := os.CreateTemp(dir, ".warmbly-upgrade-*")
|
|
if err != nil {
|
|
return fmt.Errorf("cannot write to %s: %w\nIf it is system-owned, re-run the installer instead:\n curl -fsSL https://warmbly.com/cli.sh | sh", dir, err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer os.Remove(tmpName)
|
|
|
|
if _, err := tmp.Write(binary); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(tmpName, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpName, target); err != nil {
|
|
return fmt.Errorf("could not replace %s: %w", target, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func download(ctx context.Context, url string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client := &http.Client{Timeout: 5 * time.Minute}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not download %s: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("could not download %s: HTTP %d", url, resp.StatusCode)
|
|
}
|
|
return io.ReadAll(io.LimitReader(resp.Body, 200<<20))
|
|
}
|
|
|
|
func checksumFor(sums, asset string) string {
|
|
for _, line := range strings.Split(sums, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == asset {
|
|
return fields[0]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// extractBinary pulls just the warmbly executable out of the release archive.
|
|
func extractBinary(archive []byte) ([]byte, error) {
|
|
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the archive is not readable: %w", err)
|
|
}
|
|
defer gz.Close()
|
|
|
|
reader := tar.NewReader(gz)
|
|
for {
|
|
header, err := reader.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if header.Typeflag != tar.TypeReg {
|
|
continue
|
|
}
|
|
if filepath.Base(header.Name) != "warmbly" {
|
|
continue
|
|
}
|
|
return io.ReadAll(io.LimitReader(reader, 200<<20))
|
|
}
|
|
return nil, errors.New("the archive did not contain a warmbly binary")
|
|
}
|