feat: add version and self update commands
This commit is contained in:
1 parent
9cb005fe53
commit
efd7d74b74
6 files changed
+640
-5
No files matched your search
+1
-1
@@ -20,7 +20,7 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -s -w -X main.version={{ .Version }} -X main.commit={{ .Commit }} -X main.date={{ .Date }}
|
||||
|
||||
archives:
|
||||
- id: ktui
|
||||
|
||||
@@ -52,6 +52,19 @@ go run ./cmd/ktui
|
||||
./ktui help keys
|
||||
```
|
||||
|
||||
查看当前版本:
|
||||
|
||||
```sh
|
||||
./ktui version
|
||||
```
|
||||
|
||||
检查或安装更新:
|
||||
|
||||
```sh
|
||||
./ktui update --check
|
||||
./ktui update
|
||||
```
|
||||
|
||||
指定 Komari 地址:
|
||||
|
||||
```sh
|
||||
@@ -202,6 +215,40 @@ EXP -
|
||||
- `a`:切换 ASCII 兼容模式
|
||||
- `q` / `Ctrl-C`:在列表页退出
|
||||
|
||||
## 版本信息
|
||||
|
||||
查看当前二进制版本:
|
||||
|
||||
```sh
|
||||
ktui version
|
||||
```
|
||||
|
||||
通过 GoReleaser 发布的二进制会显示 tag 版本、commit 和构建时间;本地直接 `go build` 的版本会显示为 `dev`。
|
||||
|
||||
## 自更新
|
||||
|
||||
`ktui update` 会从 Gitea Release 下载当前系统和架构对应的压缩包,使用 `checksums.txt` 校验后替换当前二进制。
|
||||
|
||||
```sh
|
||||
ktui update --check
|
||||
ktui update
|
||||
ktui update --tag v0.1.0
|
||||
```
|
||||
|
||||
默认更新源:
|
||||
|
||||
```text
|
||||
https://gitea.bytevibe.dev/api/v1/repos/gary/ktui
|
||||
```
|
||||
|
||||
如果仓库是私有仓库,可以通过环境变量传入 token:
|
||||
|
||||
```sh
|
||||
KTUI_UPDATE_TOKEN=your_token ktui update
|
||||
```
|
||||
|
||||
Windows 无法在程序运行时直接替换当前 `.exe`,`ktui update` 会下载新文件并提示手动替换。
|
||||
|
||||
## 详情页
|
||||
|
||||
详情页有五个标签:
|
||||
|
||||
+100
-4
@@ -12,6 +12,13 @@ import (
|
||||
"ktui/internal/config"
|
||||
"ktui/internal/komari"
|
||||
"ktui/internal/tui"
|
||||
"ktui/internal/update"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -21,6 +28,16 @@ func main() {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
if len(args) > 0 && (args[0] == "version" || args[0] == "--version" || args[0] == "-v") {
|
||||
printVersion()
|
||||
return
|
||||
}
|
||||
if len(args) > 0 && args[0] == "update" {
|
||||
if err := handleUpdate(args[1:]); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(args) > 0 && args[0] == "config" {
|
||||
if err := handleConfig(args[1:]); err != nil {
|
||||
fatal(err)
|
||||
@@ -33,6 +50,9 @@ func main() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(args) > 0 && looksLikeCommand(args[0]) {
|
||||
usageError(fmt.Errorf("unknown command %q", args[0]))
|
||||
}
|
||||
|
||||
cfg, cfgPath, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -80,6 +100,9 @@ func main() {
|
||||
if err := flags.Parse(args); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if flags.NArg() > 0 {
|
||||
usageError(fmt.Errorf("unexpected argument %q", flags.Arg(0)))
|
||||
}
|
||||
|
||||
mode := tui.ModeSheet
|
||||
if cfg.Mode == "line" {
|
||||
@@ -222,7 +245,7 @@ func handleConfig(args []string) error {
|
||||
|
||||
func handleHelp(args []string) error {
|
||||
if len(args) > 1 {
|
||||
return fmt.Errorf("usage: ktui help [config|keys]")
|
||||
return fmt.Errorf("usage: ktui help [config|keys|update]")
|
||||
}
|
||||
if len(args) == 0 {
|
||||
printHelp()
|
||||
@@ -233,19 +256,49 @@ func handleHelp(args []string) error {
|
||||
printConfigHelp()
|
||||
case "keys":
|
||||
printKeysHelp()
|
||||
case "update":
|
||||
printUpdateHelp()
|
||||
default:
|
||||
return fmt.Errorf("unknown help topic %q", args[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleUpdate(args []string) error {
|
||||
fs := flag.NewFlagSet("ktui update", flag.ExitOnError)
|
||||
checkOnly := fs.Bool("check", false, "check whether an update is available without installing it")
|
||||
targetTag := fs.String("tag", "", "install a specific release tag instead of the latest release")
|
||||
apiURL := fs.String("api-url", update.DefaultAPIBaseURL, "Gitea repository API URL")
|
||||
timeout := fs.Duration("timeout", 60*time.Second, "update HTTP timeout")
|
||||
fs.Usage = printUpdateHelp
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
return fmt.Errorf("unexpected update argument %q", fs.Arg(0))
|
||||
}
|
||||
return update.Run(context.Background(), update.Options{
|
||||
APIBaseURL: *apiURL,
|
||||
CurrentVersion: version,
|
||||
TargetVersion: *targetTag,
|
||||
CheckOnly: *checkOnly,
|
||||
Timeout: *timeout,
|
||||
Stdout: os.Stdout,
|
||||
})
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Print(`ktui - Komari terminal UI
|
||||
fmt.Print(helpText)
|
||||
}
|
||||
|
||||
const helpText = `ktui - Komari terminal UI
|
||||
|
||||
Usage:
|
||||
ktui [flags]
|
||||
ktui version
|
||||
ktui update [--check] [--tag v0.1.0]
|
||||
ktui config <path|init|show|set|help>
|
||||
ktui help [config|keys]
|
||||
ktui help [config|keys|update]
|
||||
|
||||
Flags:
|
||||
--url URL Komari base URL
|
||||
@@ -263,10 +316,46 @@ Examples:
|
||||
ktui
|
||||
ktui --sheet
|
||||
ktui --line --ascii --no-color
|
||||
ktui version
|
||||
ktui update --check
|
||||
ktui config init
|
||||
ktui config set api-key your_api_key
|
||||
ktui help keys
|
||||
`)
|
||||
`
|
||||
|
||||
func printVersion() {
|
||||
fmt.Printf("ktui %s\n", version)
|
||||
fmt.Printf("commit: %s\n", commit)
|
||||
fmt.Printf("built: %s\n", date)
|
||||
}
|
||||
|
||||
func printUpdateHelp() {
|
||||
fmt.Printf(`ktui update - update ktui from Gitea Releases
|
||||
|
||||
Usage:
|
||||
ktui update [flags]
|
||||
|
||||
Flags:
|
||||
--check check for an update without installing it
|
||||
--tag TAG install a specific release tag, for example v0.1.0
|
||||
--api-url URL Gitea repository API URL
|
||||
--timeout 60s HTTP timeout
|
||||
|
||||
Default API URL:
|
||||
%s
|
||||
|
||||
Examples:
|
||||
ktui update --check
|
||||
ktui update
|
||||
ktui update --tag v0.1.0
|
||||
|
||||
Private repositories:
|
||||
KTUI_UPDATE_TOKEN=your_token ktui update
|
||||
`, update.DefaultAPIBaseURL)
|
||||
}
|
||||
|
||||
func looksLikeCommand(arg string) bool {
|
||||
return arg != "" && !strings.HasPrefix(arg, "-")
|
||||
}
|
||||
|
||||
func printConfigHelp() {
|
||||
@@ -355,6 +444,13 @@ func fatal(err error) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func usageError(err error) {
|
||||
fmt.Fprintln(os.Stderr, "ktui:", err)
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fmt.Fprint(os.Stderr, helpText)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func printSummary(snapshot komari.Snapshot) {
|
||||
title := snapshot.Public.SiteName
|
||||
if title == "" {
|
||||
|
||||
@@ -33,3 +33,15 @@ func TestSplitConfigArgEqualsValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeCommand(t *testing.T) {
|
||||
if !looksLikeCommand("add") {
|
||||
t.Fatal("add should look like a command")
|
||||
}
|
||||
if looksLikeCommand("--sheet") {
|
||||
t.Fatal("--sheet should not look like a command")
|
||||
}
|
||||
if looksLikeCommand("") {
|
||||
t.Fatal("empty string should not look like a command")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultAPIBaseURL = "https://gitea.bytevibe.dev/api/v1/repos/gary/ktui"
|
||||
|
||||
type Options struct {
|
||||
APIBaseURL string
|
||||
CurrentVersion string
|
||||
TargetVersion string
|
||||
CheckOnly bool
|
||||
Timeout time.Duration
|
||||
Stdout io.Writer
|
||||
}
|
||||
|
||||
type release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
Assets []asset `json:"assets"`
|
||||
}
|
||||
|
||||
type asset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, opts Options) error {
|
||||
if opts.APIBaseURL == "" {
|
||||
opts.APIBaseURL = DefaultAPIBaseURL
|
||||
}
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = 60 * time.Second
|
||||
}
|
||||
if opts.Stdout == nil {
|
||||
opts.Stdout = os.Stdout
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
|
||||
client := &http.Client{Timeout: opts.Timeout}
|
||||
rel, err := fetchRelease(ctx, client, opts.APIBaseURL, opts.TargetVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel.TagName == "" {
|
||||
return errors.New("release response does not include a tag name")
|
||||
}
|
||||
|
||||
if sameVersion(opts.CurrentVersion, rel.TagName) {
|
||||
fmt.Fprintf(opts.Stdout, "ktui is already up to date (%s)\n", displayVersion(rel.TagName))
|
||||
return nil
|
||||
}
|
||||
|
||||
archiveAsset, err := selectArchiveAsset(rel.Assets, runtime.GOOS, runtime.GOARCH)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
checksumAsset, err := findAsset(rel.Assets, "checksums.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.Stdout, "latest: %s\n", displayVersion(rel.TagName))
|
||||
fmt.Fprintf(opts.Stdout, "current: %s\n", opts.CurrentVersion)
|
||||
|
||||
if opts.CheckOnly {
|
||||
fmt.Fprintf(opts.Stdout, "update available: %s\n", archiveAsset.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
checksums, err := downloadBytes(ctx, client, checksumAsset.BrowserDownloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download checksums: %w", err)
|
||||
}
|
||||
expected, ok := parseChecksum(checksums, archiveAsset.Name)
|
||||
if !ok {
|
||||
return fmt.Errorf("checksums.txt does not contain %s", archiveAsset.Name)
|
||||
}
|
||||
|
||||
archivePath, cleanup, err := downloadFile(ctx, client, archiveAsset.BrowserDownloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download %s: %w", archiveAsset.Name, err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if err := verifyChecksum(archivePath, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
binary, mode, err := extractBinary(archivePath, archiveAsset.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := installBinary(binary, mode, opts.Stdout); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.Stdout, "updated ktui to %s\n", displayVersion(rel.TagName))
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchRelease(ctx context.Context, client *http.Client, apiBaseURL, tag string) (release, error) {
|
||||
apiBaseURL = strings.TrimRight(apiBaseURL, "/")
|
||||
endpoint := apiBaseURL + "/releases/latest"
|
||||
if tag != "" {
|
||||
endpoint = apiBaseURL + "/releases/tags/" + url.PathEscape(tag)
|
||||
}
|
||||
|
||||
var rel release
|
||||
if err := getJSON(ctx, client, endpoint, &rel); err != nil {
|
||||
return release{}, err
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
func getJSON(ctx context.Context, client *http.Client, endpoint string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addAuth(req)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("GET %s: HTTP %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func downloadBytes(ctx context.Context, client *http.Client, endpoint string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addAuth(req)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("GET %s: HTTP %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func downloadFile(ctx context.Context, client *http.Client, endpoint string) (string, func(), error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
addAuth(req)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return "", nil, fmt.Errorf("GET %s: HTTP %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
file, err := os.CreateTemp("", "ktui-update-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = os.Remove(file.Name())
|
||||
}
|
||||
|
||||
if _, err := io.Copy(file, resp.Body); err != nil {
|
||||
_ = file.Close()
|
||||
cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
return file.Name(), cleanup, nil
|
||||
}
|
||||
|
||||
func addAuth(req *http.Request) {
|
||||
token := os.Getenv("KTUI_UPDATE_TOKEN")
|
||||
if token == "" {
|
||||
token = os.Getenv("GITEA_TOKEN")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
}
|
||||
|
||||
func selectArchiveAsset(assets []asset, goos, goarch string) (asset, error) {
|
||||
ext := ".tar.gz"
|
||||
if goos == "windows" {
|
||||
ext = ".zip"
|
||||
}
|
||||
suffix := "_" + goos + "_" + goarch + ext
|
||||
for _, asset := range assets {
|
||||
if strings.HasPrefix(asset.Name, "ktui_") && strings.HasSuffix(asset.Name, suffix) && asset.BrowserDownloadURL != "" {
|
||||
return asset, nil
|
||||
}
|
||||
}
|
||||
return asset{}, fmt.Errorf("release does not contain an asset for %s/%s", goos, goarch)
|
||||
}
|
||||
|
||||
func findAsset(assets []asset, name string) (asset, error) {
|
||||
for _, asset := range assets {
|
||||
if asset.Name == name && asset.BrowserDownloadURL != "" {
|
||||
return asset, nil
|
||||
}
|
||||
}
|
||||
return asset{}, fmt.Errorf("release does not contain %s", name)
|
||||
}
|
||||
|
||||
func parseChecksum(data []byte, filename string) (string, bool) {
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimPrefix(fields[len(fields)-1], "*")
|
||||
if path.Base(name) == filename || filepath.Base(name) == filename {
|
||||
return strings.ToLower(fields[0]), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func verifyChecksum(filename, expected string) error {
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return err
|
||||
}
|
||||
got := hex.EncodeToString(hash.Sum(nil))
|
||||
if !strings.EqualFold(got, expected) {
|
||||
return fmt.Errorf("checksum mismatch for %s: got %s, want %s", filepath.Base(filename), got, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractBinary(archivePath, archiveName string) ([]byte, os.FileMode, error) {
|
||||
if strings.HasSuffix(archiveName, ".zip") {
|
||||
return extractBinaryFromZip(archivePath)
|
||||
}
|
||||
return extractBinaryFromTarGZ(archivePath)
|
||||
}
|
||||
|
||||
func extractBinaryFromTarGZ(filename string) ([]byte, os.FileMode, error) {
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gzipReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzipReader)
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
if path.Base(header.Name) == binaryName() {
|
||||
data, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return data, os.FileMode(header.Mode), nil
|
||||
}
|
||||
}
|
||||
return nil, 0, fmt.Errorf("archive does not contain %s", binaryName())
|
||||
}
|
||||
|
||||
func extractBinaryFromZip(filename string) ([]byte, os.FileMode, error) {
|
||||
reader, err := zip.OpenReader(filename)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
for _, file := range reader.File {
|
||||
if path.Base(file.Name) != binaryName() {
|
||||
continue
|
||||
}
|
||||
handle, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
data, err := io.ReadAll(handle)
|
||||
_ = handle.Close()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return data, file.Mode(), nil
|
||||
}
|
||||
return nil, 0, fmt.Errorf("archive does not contain %s", binaryName())
|
||||
}
|
||||
|
||||
func installBinary(data []byte, mode os.FileMode, stdout io.Writer) error {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(executable); err == nil {
|
||||
executable = resolved
|
||||
}
|
||||
|
||||
mode = installMode(executable, mode)
|
||||
if runtime.GOOS == "windows" {
|
||||
next := executable + ".new"
|
||||
if err := os.WriteFile(next, data, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stdout, "downloaded update to %s\n", next)
|
||||
fmt.Fprintf(stdout, "Windows cannot replace a running executable. Close ktui and replace %s manually.\n", executable)
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := filepath.Dir(executable)
|
||||
temp, err := os.CreateTemp(dir, "."+filepath.Base(executable)+".new-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempName := temp.Name()
|
||||
defer func() {
|
||||
_ = os.Remove(tempName)
|
||||
}()
|
||||
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tempName, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempName, executable); err != nil {
|
||||
return fmt.Errorf("replace %s: %w", executable, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installMode(executable string, fallback os.FileMode) os.FileMode {
|
||||
if info, err := os.Stat(executable); err == nil {
|
||||
return info.Mode().Perm() | 0o111
|
||||
}
|
||||
mode := fallback.Perm()
|
||||
if mode == 0 {
|
||||
mode = 0o755
|
||||
}
|
||||
return mode | 0o111
|
||||
}
|
||||
|
||||
func binaryName() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "ktui.exe"
|
||||
}
|
||||
return "ktui"
|
||||
}
|
||||
|
||||
func sameVersion(current, tag string) bool {
|
||||
current = normalizeVersion(current)
|
||||
tag = normalizeVersion(tag)
|
||||
if current == "" || current == "dev" || current == "none" {
|
||||
return false
|
||||
}
|
||||
return current == tag
|
||||
}
|
||||
|
||||
func normalizeVersion(value string) string {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
value = strings.TrimPrefix(value, "v")
|
||||
return value
|
||||
}
|
||||
|
||||
func displayVersion(tag string) string {
|
||||
return strings.TrimSpace(tag)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package update
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSelectArchiveAsset(t *testing.T) {
|
||||
assets := []asset{
|
||||
{Name: "ktui_v0.1.0_linux_amd64.tar.gz", BrowserDownloadURL: "https://example.com/linux"},
|
||||
{Name: "ktui_v0.1.0_windows_amd64.zip", BrowserDownloadURL: "https://example.com/windows"},
|
||||
}
|
||||
|
||||
got, err := selectArchiveAsset(assets, "linux", "amd64")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "ktui_v0.1.0_linux_amd64.tar.gz" {
|
||||
t.Fatalf("asset = %q", got.Name)
|
||||
}
|
||||
|
||||
got, err = selectArchiveAsset(assets, "windows", "amd64")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "ktui_v0.1.0_windows_amd64.zip" {
|
||||
t.Fatalf("asset = %q", got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChecksum(t *testing.T) {
|
||||
data := []byte("abc123 ktui_v0.1.0_linux_amd64.tar.gz\n")
|
||||
got, ok := parseChecksum(data, "ktui_v0.1.0_linux_amd64.tar.gz")
|
||||
if !ok {
|
||||
t.Fatal("checksum not found")
|
||||
}
|
||||
if got != "abc123" {
|
||||
t.Fatalf("checksum = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameVersion(t *testing.T) {
|
||||
if !sameVersion("v0.1.0", "v0.1.0") {
|
||||
t.Fatal("same v-prefixed versions should match")
|
||||
}
|
||||
if !sameVersion("0.1.0", "v0.1.0") {
|
||||
t.Fatal("version should match tag without v prefix")
|
||||
}
|
||||
if sameVersion("dev", "v0.1.0") {
|
||||
t.Fatal("dev version should never be considered up to date")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user