feat: add version and self update commands
Release / goreleaser (push) Successful in 10m25s
CI / test (push) Successful in 54s

This commit is contained in:
gary committed 2026-07-01 13:05:21 +08:00
1 parent 9cb005fe53
commit efd7d74b74
6 files changed
+640 -5

No files matched your search

+1 -1
View File
@@ -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
+47
View File
@@ -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
View File
@@ -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 == "" {
+12
View File
@@ -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")
}
}
+431
View File
@@ -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)
}
+49
View File
@@ -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")
}
}