yao/cmd/upgrade.go
Max 890ca3e25a refactor(tests): rename and consolidate input building functions for clarity
- Renamed `buildInput` to `buildLastUserMessageJSONL` to better reflect its purpose of constructing JSONL from the last user message.
- Updated test cases to use the new function, ensuring they accurately verify the behavior of skipping system messages and only including the last user message.
- Removed the now redundant `buildFirstRequestJSONL` function, streamlining the input building process in the Claude command tests.
2026-03-27 09:30:35 +08:00

258 lines
7 KiB
Go

package cmd
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/yaoapp/yao/share"
)
const githubReleasesAPI = "https://api.github.com/repos/YaoApp/yao/releases/latest"
// githubRelease represents a GitHub release response
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Prerelease bool `json:"prerelease"`
Assets []githubAsset `json:"assets"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
}
// githubAsset represents a single release asset
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Size int64 `json:"size"`
}
var upgradeCmd = &cobra.Command{
Use: "upgrade",
Short: L("Upgrade yao to latest version"),
Long: L("Upgrade yao to latest version"),
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("%s %s\n", color.WhiteString(L("Current version:")), color.CyanString(share.VERSION))
fmt.Println(color.WhiteString(L("Checking latest version...")))
release, err := fetchLatestRelease()
if err != nil {
fmt.Println(color.RedString(L("Fatal: %s"), err.Error()))
os.Exit(1)
}
latestVersion := strings.TrimPrefix(release.TagName, "v")
fmt.Printf("%s %s\n", color.WhiteString(L("Latest version: ")), color.GreenString(latestVersion))
if latestVersion == share.VERSION {
fmt.Println(color.GreenString(L("🎉Current version is the latest🎉")))
os.Exit(0)
}
assetName := buildAssetName(latestVersion)
asset := findAsset(release.Assets, assetName)
if asset == nil {
fmt.Println(color.RedString(L("Fatal: %s"), fmt.Sprintf("asset not found: %s", assetName)))
fmt.Printf("%s %s\n", color.WhiteString(L("Available assets:")), "")
for _, a := range release.Assets {
if !strings.HasSuffix(a.Name, ".sha256") && !strings.HasSuffix(a.Name, ".zip") && !strings.HasSuffix(a.Name, ".tar.gz") {
fmt.Printf(" - %s\n", color.YellowString(a.Name))
}
}
os.Exit(1)
}
fmt.Printf("%s %s\n", color.WhiteString(L("Do you want to update to %s ? (y/n): "), latestVersion), "")
fmt.Print("> ")
input, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
fmt.Println(color.RedString(L("Fatal: %s"), err.Error()))
os.Exit(1)
}
input = strings.TrimSpace(input)
if input != "y" && input != "Y" {
fmt.Println(color.YellowString(L("Canceled upgrade")))
return
}
exe, err := os.Executable()
if err != nil {
fmt.Println(color.RedString(L("Fatal: %s"), err.Error()))
os.Exit(1)
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
fmt.Println(color.RedString(L("Fatal: %s"), err.Error()))
os.Exit(1)
}
fmt.Printf("%s %s\n", color.WhiteString(L("Downloading...")), color.CyanString(asset.BrowserDownloadURL))
if err := downloadAndReplace(asset.BrowserDownloadURL, exe); err != nil {
fmt.Println(color.RedString(L("Error occurred while updating binary: %s"), err.Error()))
os.Exit(1)
}
fmt.Println(color.GreenString(L("🎉Successfully updated to version: %s🎉"), latestVersion))
},
}
// fetchLatestRelease fetches the latest release from GitHub API
func fetchLatestRelease() (*githubRelease, error) {
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("GET", githubReleasesAPI, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", fmt.Sprintf("yao/%s", share.VERSION))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to parse release info: %w", err)
}
return &release, nil
}
// buildAssetName constructs the expected asset filename for the current platform
func buildAssetName(version string) string {
goos := runtime.GOOS
goarch := runtime.GOARCH
// normalize arch names
if goarch == "amd64" {
goarch = "amd64"
} else if goarch == "arm64" {
goarch = "arm64"
}
return fmt.Sprintf("yao-%s-%s-%s", version, goos, goarch)
}
// findAsset finds the matching asset by name prefix
func findAsset(assets []githubAsset, name string) *githubAsset {
for i, a := range assets {
if a.Name == name {
return &assets[i]
}
}
return nil
}
// downloadAndReplace downloads the new binary and replaces the current executable
func downloadAndReplace(url, exePath string) error {
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
// write to a temp file in the same directory as the executable
dir := filepath.Dir(exePath)
tmpFile, err := os.CreateTemp(dir, ".yao-upgrade-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer func() {
tmpFile.Close()
os.Remove(tmpPath)
}()
total := resp.ContentLength
var downloaded int64
buf := make([]byte, 32*1024)
lastPrint := time.Now()
for {
n, err := resp.Body.Read(buf)
if n > 0 {
if _, werr := tmpFile.Write(buf[:n]); werr != nil {
return fmt.Errorf("write failed: %w", werr)
}
downloaded += int64(n)
if time.Since(lastPrint) > 500*time.Millisecond || err == io.EOF {
if total > 0 {
pct := float64(downloaded) / float64(total) * 100
fmt.Printf("\r %s %.1f%% (%d / %d MB)",
color.CyanString(L("Progress:")),
pct,
downloaded/1024/1024,
total/1024/1024,
)
} else {
fmt.Printf("\r %s %d MB downloaded", color.CyanString(L("Progress:")), downloaded/1024/1024)
}
lastPrint = time.Now()
}
}
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("download interrupted: %w", err)
}
}
fmt.Println()
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
// make executable
if err := os.Chmod(tmpPath, 0755); err != nil {
return fmt.Errorf("failed to chmod: %w", err)
}
// atomically replace the executable
if err := os.Rename(tmpPath, exePath); err != nil {
// on some systems (cross-device) rename fails, fall back to copy
if err2 := copyFile(tmpPath, exePath); err2 != nil {
return fmt.Errorf("replace failed: %w (copy fallback: %v)", err, err2)
}
}
return nil
}
// copyFile copies src to dst, used as fallback when rename fails cross-device
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}