diff --git a/.github/scripts/release-notes.sh b/.github/scripts/release-notes.sh new file mode 100755 index 0000000..3c88fd0 --- /dev/null +++ b/.github/scripts/release-notes.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# +# Extracts the CHANGELOG.md section for a version and formats it as the GitHub +# release body. Usage: release-notes.sh v1.5.0 +# +# Exits non-zero when the version has no section, so a tag can never be +# published with empty or stale release notes. + +set -euo pipefail + +if [ $# -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +version="${1#v}" +changelog="${CHANGELOG_FILE:-CHANGELOG.md}" + +if [ ! -f "$changelog" ]; then + echo "$changelog not found" >&2 + exit 1 +fi + +notes=$(awk -v ver="$version" ' + BEGIN { heading = "## [" ver "]" } + index($0, heading) == 1 { found = 1; next } + found && index($0, "## [") == 1 { exit } + found { print } +' "$changelog") + +if [ -z "${notes//[[:space:]]/}" ]; then + echo "no $changelog section found for version $version" >&2 + exit 1 +fi + +printf "## What's new in v%s\n%s\n" "$version" "$notes" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d8f7ca4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: ci + +on: + push: + branches: ['**'] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "Not gofmt'd:" + echo "$unformatted" + exit 1 + fi + + - run: go vet ./... + + - run: go build ./... + + # Catches a broken release config before a tag is pushed. + - uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7f285e8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # Fails the release if CHANGELOG.md has no section for this tag. + - name: Build release notes from CHANGELOG.md + run: .github/scripts/release-notes.sh "${{ github.ref_name }}" > "${RUNNER_TEMP}/release-notes.md" + + - uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --clean --release-notes=${{ runner.temp }}/release-notes.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..8128438 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,47 @@ +version: 2 + +project_name: godeez + +before: + hooks: + - go mod tidy + +builds: + - id: godeez + main: . + binary: godeez + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w + - -X github.com/mathismqn/godeez/internal/buildinfo.version={{ .Version }} + - -X github.com/mathismqn/godeez/internal/buildinfo.commit={{ .FullCommit }} + - -X github.com/mathismqn/godeez/internal/buildinfo.date={{ .CommitDate }} + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + +archives: + - formats: + - binary + name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' + +checksum: + name_template: checksums.txt + algorithm: sha256 + +snapshot: + version_template: '{{ incpatch .Version }}-snapshot' + +# Release notes are written by hand in CHANGELOG.md +changelog: + disable: true + +release: + prerelease: auto diff --git a/README.md b/README.md index 80bcdae..c83cbb2 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,11 @@ A simple Go tool for downloading music from [Deezer](https://www.deezer.com). [Features](#features) • [Installation](#installation) • +[Updating](#updating) • [Configuration](#configuration) • [Usage](#usage) • [Contributing](#contributing) • -[Support](#⭐-support-the-project) • +[Support](#support-the-project) • [License](#license) @@ -33,14 +34,58 @@ A simple Go tool for downloading music from [Deezer](https://www.deezer.com). To install **GoDeez**, download the latest binary for your platform from the [Releases](https://github.com/mathismqn/godeez/releases) page. 1. Go to the [Releases](https://github.com/mathismqn/godeez/releases) page. -2. Download the appropriate binary for your operating system (Windows, macOS, or Linux). +2. Download the appropriate binary for your operating system and architecture, named `godeez___`. 3. (Optional) Move the binary to a directory included in `$PATH` for easier access. Example (Linux/macOS): ```bash -# Move the downloaded binary to /usr/local/bin for easy access from anywhere -mv godeez-1.4.0-linux-amd64 /usr/local/bin/godeez +# Make it executable and move it to /usr/local/bin for access from anywhere +chmod +x godeez_1.5.0_linux_amd64 +mv godeez_1.5.0_linux_amd64 /usr/local/bin/godeez +``` + +Every release also ships a `checksums.txt`, so you can verify a download: + +```bash +sha256sum -c checksums.txt --ignore-missing +``` + +### macOS + +The macOS binaries are not signed with an Apple Developer certificate, so +Gatekeeper blocks them the first time. Clear the quarantine flag once: + +```bash +xattr -d com.apple.quarantine /usr/local/bin/godeez +``` + +## Updating + +**GoDeez** can replace itself with the latest release: + +```bash +# See whether a new version exists +godeez update --check + +# Download, verify, and install it +godeez update +``` + +The new binary is verified against the release's published SHA256 checksum +before it replaces the current one. If **GoDeez** lives in a directory you do +not own (such as `/usr/local/bin` on some systems), run `sudo godeez update`. + +To disable the notice about new versions: + +```bash +export GODEEZ_NO_UPDATE_CHECK=1 +``` + +To see what you are running: + +```bash +godeez version ``` ## Configuration @@ -48,7 +93,7 @@ mv godeez-1.4.0-linux-amd64 /usr/local/bin/godeez **GoDeez** requires a Deezer ARL cookie for authentication. Set it as an environment variable: ```bash -export DEEZER_ARL=”your_arl_cookie_here” +export DEEZER_ARL="your_arl_cookie_here" ``` To make it persistent, add the line above to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.). @@ -85,6 +130,8 @@ Available Commands: completion Generate the autocompletion script for the specified shell download Download songs from Deezer help Help about any command + update Update GoDeez to the latest version + version Print the GoDeez version Flags: -h, --help help for godeez @@ -108,7 +155,6 @@ Available Commands: Flags: --bpm fetch BPM/key and add to file tags - --config string config file (default ~/.godeez/config.toml) --genre fetch genre and add to file tags -h, --help help for download -q, --quality string download quality [mp3_128, mp3_320, flac] (default "mp3_320") @@ -144,9 +190,9 @@ Whether it’s a bug fix, a new feature, or improving documentation, your input If you have an idea for improvement, feel free to fork the repository and submit a pull request. You can also open an issue if you spot a bug or have a feature suggestion. -## ⭐ Support the Project +## Support the Project -If **GoDeez** helps you enjoy your music collection, please consider giving it a star! +If **GoDeez** helps you enjoy your music collection, please consider giving it a ⭐! **Why star us?** diff --git a/cmd/download.go b/cmd/download.go index f47940e..2284d41 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -19,8 +19,9 @@ const appConfigKey contextKey = "appConfig" var opts downloader.Options var downloadCmd = &cobra.Command{ - Use: "download", - Short: "Download songs from Deezer", + Use: "download", + Short: "Download songs from Deezer", + Annotations: map[string]string{updateNoticeAnnotation: "true"}, } func init() { diff --git a/cmd/root.go b/cmd/root.go index 46dd93b..f3a41f2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,11 +1,75 @@ package cmd import ( + "context" + "fmt" + "os" + "slices" + + "github.com/mathismqn/godeez/internal/buildinfo" + "github.com/mathismqn/godeez/internal/updater" "github.com/spf13/cobra" + "golang.org/x/term" ) +const updateNoticeAnnotation = "godeez:update-notice" + var RootCmd = &cobra.Command{ Use: "godeez", Short: "GoDeez is a tool to download music from Deezer", SilenceUsage: true, } + +func Execute(ctx context.Context) error { + var notice <-chan string + if wantsUpdateNotice() { + notice = updater.StartCheck(ctx) + } + + err := RootCmd.ExecuteContext(ctx) + + printUpdateNotice(notice) + + return err +} + +func wantsUpdateNotice() bool { + if !term.IsTerminal(int(os.Stderr.Fd())) { + return false + } + + args := os.Args[1:] + if slices.Contains(args, "-h") || slices.Contains(args, "--help") { + return false + } + + target, _, err := RootCmd.Find(args) + if err != nil || target == nil { + return false + } + + if target.Run == nil && target.RunE == nil { + return false + } + + for cmd := target; cmd != nil; cmd = cmd.Parent() { + if cmd.Annotations[updateNoticeAnnotation] == "true" { + return true + } + } + + return false +} + +func printUpdateNotice(notice <-chan string) { + select { + case latest := <-notice: + if latest == "" { + return + } + + fmt.Fprintf(os.Stderr, "\n ┌ Update available: %s → %s\n └ Run `godeez update` to install\n", + buildinfo.Version(), latest) + default: + } +} diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..2944fb7 --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/mathismqn/godeez/internal/buildinfo" + "github.com/mathismqn/godeez/internal/updater" + "github.com/spf13/cobra" +) + +var updateOpts struct { + checkOnly bool + force bool +} + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update GoDeez to the latest version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + err := runUpdate(cmd.Context()) + if errors.Is(err, context.Canceled) { + return nil + } + + return err + }, +} + +func runUpdate(ctx context.Context) error { + if err := updater.CheckUpdatable(); err != nil { + return err + } + + u := updater.New() + u.Out = os.Stdout + current := buildinfo.Version() + + release, err := u.Latest(ctx) + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + latest := release.Version() + fmt.Printf("Current: %s\nLatest: %s\n", current, latest) + + if !updater.IsNewer(current, latest) && !updateOpts.force { + fmt.Println("Already up to date.") + + return nil + } + + if updateOpts.checkOnly { + fmt.Printf("Run `godeez update` to install %s.\n", latest) + + return nil + } + + if err := u.Apply(ctx, release); err != nil { + return err + } + fmt.Printf("Updated to %s.\n", latest) + + return nil +} + +func init() { + RootCmd.AddCommand(updateCmd) + + updateCmd.Flags().BoolVar(&updateOpts.checkOnly, "check", false, "only report whether an update is available") + updateCmd.Flags().BoolVar(&updateOpts.force, "force", false, "reinstall even if already up to date") +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..77f0f48 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "fmt" + "runtime" + + "github.com/mathismqn/godeez/internal/buildinfo" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the current version of GoDeez", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("godeez %s\n", buildinfo.Version()) + + if commit := buildinfo.Commit(); commit != "" { + fmt.Printf(" commit: %s\n", commit) + } + if date := buildinfo.Date(); date != "" { + fmt.Printf(" built: %s\n", date) + } + + fmt.Printf(" go: %s\n", runtime.Version()) + fmt.Printf(" platform: %s/%s\n", runtime.GOOS, runtime.GOARCH) + }, +} + +func init() { + RootCmd.AddCommand(versionCmd) +} diff --git a/go.mod b/go.mod index 59c25e1..63fbbba 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 + golang.org/x/mod v0.38.0 golang.org/x/term v0.45.0 ) diff --git a/go.sum b/go.sum index fcd2e73..e5a3c5e 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..8a62622 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,74 @@ +package buildinfo + +import ( + "fmt" + "runtime" + "runtime/debug" + "strings" + + "golang.org/x/mod/module" + "golang.org/x/mod/semver" +) + +// devVersion is the version reported by builds that were not produced by a +// release. Both the update check and `godeez update` refuse to run on them. +const devVersion = "dev" + +var ( + version = devVersion + commit = "" + date = "" +) + +func Version() string { + if version != devVersion { + return version + } + + if info, ok := debug.ReadBuildInfo(); ok { + if v := releaseVersion(info.Main.Version); v != "" { + return v + } + } + + return devVersion +} + +func releaseVersion(v string) string { + if !semver.IsValid(v) { + return "" + } + if semver.Build(v) != "" || module.IsPseudoVersion(v) { + return "" + } + + return strings.TrimPrefix(v, "v") +} + +func IsDev() bool { + return Version() == devVersion +} + +func Commit() string { + if commit != "" { + return commit + } + + if info, ok := debug.ReadBuildInfo(); ok { + for _, s := range info.Settings { + if s.Key == "vcs.revision" { + return s.Value + } + } + } + + return "" +} + +func Date() string { + return date +} + +func UserAgent() string { + return fmt.Sprintf("godeez/%s (%s/%s)", Version(), runtime.GOOS, runtime.GOARCH) +} diff --git a/internal/updater/apply.go b/internal/updater/apply.go new file mode 100644 index 0000000..9a4c549 --- /dev/null +++ b/internal/updater/apply.go @@ -0,0 +1,208 @@ +package updater + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/mathismqn/godeez/internal/buildinfo" + "github.com/mathismqn/godeez/internal/fileutil" +) + +var managedPrefixes = []string{ + "/nix/store", + "/opt/homebrew", + "/usr/local/Cellar", + "/home/linuxbrew", + "/snap", + "/var/lib/flatpak", +} + +func resolveTarget() (string, error) { + if buildinfo.IsDev() { + return "", fmt.Errorf("development build cannot self-update. Install a release from https://github.com/%s/%s/releases", + repoOwner, repoName) + } + + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("failed to locate the running binary: %w", err) + } + + target := exe + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + target = resolved + } + + for _, prefix := range managedPrefixes { + if strings.HasPrefix(target, prefix) { + return "", fmt.Errorf("%s was installed by a package manager. Update it with that instead", target) + } + } + + return target, nil +} + +func CheckUpdatable() error { + _, err := resolveTarget() + + return err +} + +func checkWritable(dir string) error { + f, err := os.CreateTemp(dir, tmpPattern) + if err != nil { + hint := "Re-run with sudo" + if runtime.GOOS == "windows" { + hint = "Re-run from an elevated prompt" + } + + return fmt.Errorf("cannot write to %s: %w. %s", dir, err, hint) + } + + name := f.Name() + f.Close() + os.Remove(name) + + return nil +} + +func (u *Updater) Apply(ctx context.Context, release *Release) error { + target, err := resolveTarget() + if err != nil { + return err + } + + dir := filepath.Dir(target) + if err := checkWritable(dir); err != nil { + return err + } + + asset, err := release.assetForRuntime() + if err != nil { + return err + } + + want, err := u.fetchChecksum(ctx, release, asset.Name) + if err != nil { + return err + } + + u.step("Downloading %s", asset.Name) + tmp, sum, err := u.download(ctx, dir, asset) + if err != nil { + return err + } + defer fileutil.DeleteFile(tmp) + + u.step("Verifying checksum") + if sum != want { + return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", asset.Name, want, sum) + } + + if err := os.Chmod(tmp, 0755); err != nil { + return err + } + + u.step("Replacing %s", target) + + return replaceBinary(target, tmp) +} + +func (u *Updater) fetchChecksum(ctx context.Context, release *Release, assetName string) (string, error) { + asset, ok := release.asset(checksumsAsset) + if !ok { + return "", fmt.Errorf("release %s does not publish %s", release.TagName, checksumsAsset) + } + + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + + body, err := u.get(ctx, asset.URL, nil) + if err != nil { + return "", err + } + defer body.Close() + + return parseChecksums(io.LimitReader(body, maxResponseSize), assetName) +} + +func parseChecksums(r io.Reader, name string) (string, error) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) != 2 { + continue + } + if strings.TrimPrefix(fields[1], "*") == name { + return strings.ToLower(fields[0]), nil + } + } + if err := scanner.Err(); err != nil { + return "", err + } + + return "", fmt.Errorf("no checksum listed for %s", name) +} + +func (u *Updater) download(ctx context.Context, dir string, asset Asset) (string, string, error) { + body, err := u.get(ctx, asset.URL, nil) + if err != nil { + return "", "", err + } + defer body.Close() + + f, err := os.CreateTemp(dir, tmpPattern) + if err != nil { + return "", "", err + } + tmp := f.Name() + + hash := sha256.New() + if _, err := io.Copy(io.MultiWriter(f, hash), body); err != nil { + f.Close() + fileutil.DeleteFile(tmp) + + return "", "", fmt.Errorf("failed to download %s: %w", asset.Name, err) + } + if err := f.Close(); err != nil { + fileutil.DeleteFile(tmp) + + return "", "", err + } + + return tmp, hex.EncodeToString(hash.Sum(nil)), nil +} + +func replaceBinary(target, tmp string) error { + if runtime.GOOS != "windows" { + return os.Rename(tmp, target) + } + + old := target + ".old" + os.Remove(old) + + if err := os.Rename(target, old); err != nil { + return fmt.Errorf("failed to move the current binary aside: %w", err) + } + + if err := os.Rename(tmp, target); err != nil { + if rollbackErr := os.Rename(old, target); rollbackErr != nil { + return fmt.Errorf("failed to install the new binary: %w. The previous one could not be restored from %s: %v", + err, old, rollbackErr) + } + + return fmt.Errorf("failed to install the new binary: %w", err) + } + + os.Remove(old) + + return nil +} diff --git a/internal/updater/check.go b/internal/updater/check.go new file mode 100644 index 0000000..a68ed27 --- /dev/null +++ b/internal/updater/check.go @@ -0,0 +1,118 @@ +package updater + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "time" + + "github.com/mathismqn/godeez/internal/buildinfo" + "github.com/mathismqn/godeez/internal/fileutil" +) + +const noCheckEnv = "GODEEZ_NO_UPDATE_CHECK" + +const ( + cacheTTL = 24 * time.Hour + checkTimeout = 3 * time.Second +) + +type cacheEntry struct { + CheckedAt time.Time `json:"checked_at"` + LatestVersion string `json:"latest_version"` +} + +func cachePath() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + + return filepath.Join(dir, "godeez", "update.json"), nil +} + +func readCache() (cacheEntry, bool) { + path, err := cachePath() + if err != nil { + return cacheEntry{}, false + } + + data, err := os.ReadFile(path) + if err != nil { + return cacheEntry{}, false + } + + var entry cacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + return cacheEntry{}, false + } + if entry.LatestVersion == "" || time.Since(entry.CheckedAt) > cacheTTL { + return cacheEntry{}, false + } + + return entry, true +} + +func writeCache(version string) error { + path, err := cachePath() + if err != nil { + return err + } + if err := fileutil.EnsureDir(filepath.Dir(path)); err != nil { + return err + } + + data, err := json.Marshal(cacheEntry{CheckedAt: time.Now(), LatestVersion: version}) + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +func check(ctx context.Context) (string, error) { + if entry, ok := readCache(); ok { + return newerThanCurrent(entry.LatestVersion), nil + } + + release, err := New().Latest(ctx) + if err != nil { + return "", err + } + + latest := release.Version() + _ = writeCache(latest) + + return newerThanCurrent(latest), nil +} + +func newerThanCurrent(latest string) string { + if IsNewer(buildinfo.Version(), latest) { + return latest + } + + return "" +} + +func StartCheck(ctx context.Context) <-chan string { + ch := make(chan string, 1) + + if os.Getenv(noCheckEnv) != "" || buildinfo.IsDev() { + close(ch) + return ch + } + + go func() { + defer close(ch) + + ctx, cancel := context.WithTimeout(ctx, checkTimeout) + defer cancel() + + if latest, err := check(ctx); err == nil && latest != "" { + ch <- latest + } + }() + + return ch +} diff --git a/internal/updater/release.go b/internal/updater/release.go new file mode 100644 index 0000000..252a986 --- /dev/null +++ b/internal/updater/release.go @@ -0,0 +1,82 @@ +package updater + +import ( + "context" + "encoding/json" + "fmt" + "io" + "runtime" +) + +const ( + repoOwner = "mathismqn" + repoName = "godeez" + latestReleaseURL = "https://api.github.com/repos/" + repoOwner + "/" + repoName + "/releases/latest" + checksumsAsset = "checksums.txt" + maxResponseSize = 1 << 20 +) + +var githubAPIHeaders = map[string]string{ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", +} + +type Release struct { + TagName string `json:"tag_name"` + Assets []Asset `json:"assets"` +} + +type Asset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` +} + +func (u *Updater) Latest(ctx context.Context) (*Release, error) { + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + + body, err := u.get(ctx, latestReleaseURL, githubAPIHeaders) + if err != nil { + return nil, err + } + defer body.Close() + + var release Release + if err := json.NewDecoder(io.LimitReader(body, maxResponseSize)).Decode(&release); err != nil { + return nil, fmt.Errorf("failed to decode release: %w", err) + } + if release.TagName == "" { + return nil, fmt.Errorf("release has no tag name") + } + + return &release, nil +} + +func (r *Release) Version() string { + return trimV(r.TagName) +} + +func (r *Release) asset(name string) (Asset, bool) { + for _, a := range r.Assets { + if a.Name == name { + return a, true + } + } + + return Asset{}, false +} + +func (r *Release) assetForRuntime() (Asset, error) { + name := fmt.Sprintf("%s_%s_%s_%s", repoName, r.Version(), runtime.GOOS, runtime.GOARCH) + if runtime.GOOS == "windows" { + name += ".exe" + } + + asset, ok := r.asset(name) + if !ok { + return Asset{}, fmt.Errorf("release %s has no binary for %s/%s (expected %s)", + r.TagName, runtime.GOOS, runtime.GOARCH, name) + } + + return asset, nil +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..0a70aee --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,55 @@ +package updater + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/mathismqn/godeez/internal/buildinfo" +) + +const ( + apiTimeout = 30 * time.Second + tmpPattern = ".godeez-update-*" +) + +type Updater struct { + client *http.Client + Out io.Writer +} + +func New() *Updater { + return &Updater{ + client: &http.Client{}, + Out: io.Discard, + } +} + +func (u *Updater) step(format string, args ...any) { + fmt.Fprintf(u.Out, format+"...\n", args...) +} + +func (u *Updater) get(ctx context.Context, url string, headers map[string]string) (io.ReadCloser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", buildinfo.UserAgent()) + for name, value := range headers { + req.Header.Set(name, value) + } + + resp, err := u.client.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + + return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, url) + } + + return resp.Body, nil +} diff --git a/internal/updater/version.go b/internal/updater/version.go new file mode 100644 index 0000000..b1ee293 --- /dev/null +++ b/internal/updater/version.go @@ -0,0 +1,35 @@ +package updater + +import ( + "strings" + + "golang.org/x/mod/semver" +) + +func trimV(v string) string { + return strings.TrimPrefix(strings.TrimSpace(v), "v") +} + +func canonical(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + if !semver.IsValid(v) { + return "" + } + + return v +} + +func IsNewer(current, latest string) bool { + c, l := canonical(current), canonical(latest) + if c == "" || l == "" { + return false + } + + return semver.Compare(l, c) > 0 +} diff --git a/main.go b/main.go index 23b8053..8adfa46 100644 --- a/main.go +++ b/main.go @@ -12,5 +12,8 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - cmd.RootCmd.ExecuteContext(ctx) + if err := cmd.Execute(ctx); err != nil { + stop() + os.Exit(1) + } }