Merge branch 'feat/auto-update' into dev
This commit is contained in:
Executable
+36
@@ -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 <version>" >&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"
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
</div>
|
||||
@@ -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_<version>_<os>_<arch>`.
|
||||
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?**
|
||||
|
||||
|
||||
+3
-2
@@ -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() {
|
||||
|
||||
+64
@@ -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:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user