From efd7d74b7411ebfcd5bac0674f0daa374e7629b0 Mon Sep 17 00:00:00 2001 From: Gary Gan Date: Wed, 1 Jul 2026 13:05:21 +0800 Subject: [PATCH] feat: add version and self update commands --- .goreleaser.yaml | 2 +- README.md | 47 ++++ cmd/ktui/main.go | 104 +++++++- cmd/ktui/main_test.go | 12 + internal/update/update.go | 431 +++++++++++++++++++++++++++++++++ internal/update/update_test.go | 49 ++++ 6 files changed, 640 insertions(+), 5 deletions(-) create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 562da0a..a9f514c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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 diff --git a/README.md b/README.md index e9e88cf..3a29151 100644 --- a/README.md +++ b/README.md @@ -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` 会下载新文件并提示手动替换。 + ## 详情页 详情页有五个标签: diff --git a/cmd/ktui/main.go b/cmd/ktui/main.go index 94a4182..6490d6e 100644 --- a/cmd/ktui/main.go +++ b/cmd/ktui/main.go @@ -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 - 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 == "" { diff --git a/cmd/ktui/main_test.go b/cmd/ktui/main_test.go index 689e22c..45d6c39 100644 --- a/cmd/ktui/main_test.go +++ b/cmd/ktui/main_test.go @@ -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") + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..4c0ab05 --- /dev/null +++ b/internal/update/update.go @@ -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) +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..cd37b3e --- /dev/null +++ b/internal/update/update_test.go @@ -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") + } +}