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
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w -X main.version={{ .Version }} -X main.commit={{ .Commit }} -X main.date={{ .Date }}
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: ktui
|
- id: ktui
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ go run ./cmd/ktui
|
|||||||
./ktui help keys
|
./ktui help keys
|
||||||
```
|
```
|
||||||
|
|
||||||
|
查看当前版本:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./ktui version
|
||||||
|
```
|
||||||
|
|
||||||
|
检查或安装更新:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./ktui update --check
|
||||||
|
./ktui update
|
||||||
|
```
|
||||||
|
|
||||||
指定 Komari 地址:
|
指定 Komari 地址:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -202,6 +215,40 @@ EXP -
|
|||||||
- `a`:切换 ASCII 兼容模式
|
- `a`:切换 ASCII 兼容模式
|
||||||
- `q` / `Ctrl-C`:在列表页退出
|
- `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/config"
|
||||||
"ktui/internal/komari"
|
"ktui/internal/komari"
|
||||||
"ktui/internal/tui"
|
"ktui/internal/tui"
|
||||||
|
"ktui/internal/update"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
version = "dev"
|
||||||
|
commit = "none"
|
||||||
|
date = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -21,6 +28,16 @@ func main() {
|
|||||||
fatal(err)
|
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 len(args) > 0 && args[0] == "config" {
|
||||||
if err := handleConfig(args[1:]); err != nil {
|
if err := handleConfig(args[1:]); err != nil {
|
||||||
fatal(err)
|
fatal(err)
|
||||||
@@ -33,6 +50,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(args) > 0 && looksLikeCommand(args[0]) {
|
||||||
|
usageError(fmt.Errorf("unknown command %q", args[0]))
|
||||||
|
}
|
||||||
|
|
||||||
cfg, cfgPath, err := config.Load()
|
cfg, cfgPath, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,6 +100,9 @@ func main() {
|
|||||||
if err := flags.Parse(args); err != nil {
|
if err := flags.Parse(args); err != nil {
|
||||||
fatal(err)
|
fatal(err)
|
||||||
}
|
}
|
||||||
|
if flags.NArg() > 0 {
|
||||||
|
usageError(fmt.Errorf("unexpected argument %q", flags.Arg(0)))
|
||||||
|
}
|
||||||
|
|
||||||
mode := tui.ModeSheet
|
mode := tui.ModeSheet
|
||||||
if cfg.Mode == "line" {
|
if cfg.Mode == "line" {
|
||||||
@@ -222,7 +245,7 @@ func handleConfig(args []string) error {
|
|||||||
|
|
||||||
func handleHelp(args []string) error {
|
func handleHelp(args []string) error {
|
||||||
if len(args) > 1 {
|
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 {
|
if len(args) == 0 {
|
||||||
printHelp()
|
printHelp()
|
||||||
@@ -233,19 +256,49 @@ func handleHelp(args []string) error {
|
|||||||
printConfigHelp()
|
printConfigHelp()
|
||||||
case "keys":
|
case "keys":
|
||||||
printKeysHelp()
|
printKeysHelp()
|
||||||
|
case "update":
|
||||||
|
printUpdateHelp()
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown help topic %q", args[0])
|
return fmt.Errorf("unknown help topic %q", args[0])
|
||||||
}
|
}
|
||||||
return nil
|
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() {
|
func printHelp() {
|
||||||
fmt.Print(`ktui - Komari terminal UI
|
fmt.Print(helpText)
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpText = `ktui - Komari terminal UI
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
ktui [flags]
|
ktui [flags]
|
||||||
|
ktui version
|
||||||
|
ktui update [--check] [--tag v0.1.0]
|
||||||
ktui config <path|init|show|set|help>
|
ktui config <path|init|show|set|help>
|
||||||
ktui help [config|keys]
|
ktui help [config|keys|update]
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--url URL Komari base URL
|
--url URL Komari base URL
|
||||||
@@ -263,10 +316,46 @@ Examples:
|
|||||||
ktui
|
ktui
|
||||||
ktui --sheet
|
ktui --sheet
|
||||||
ktui --line --ascii --no-color
|
ktui --line --ascii --no-color
|
||||||
|
ktui version
|
||||||
|
ktui update --check
|
||||||
ktui config init
|
ktui config init
|
||||||
ktui config set api-key your_api_key
|
ktui config set api-key your_api_key
|
||||||
ktui help keys
|
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() {
|
func printConfigHelp() {
|
||||||
@@ -355,6 +444,13 @@ func fatal(err error) {
|
|||||||
os.Exit(1)
|
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) {
|
func printSummary(snapshot komari.Snapshot) {
|
||||||
title := snapshot.Public.SiteName
|
title := snapshot.Public.SiteName
|
||||||
if title == "" {
|
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