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" const cdnFallbackBase = "https://get.yaoapps.com/yao" // Upgrade command flags var ( upgradeYes bool upgradeCheck bool upgradeSource string ) // 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"` } // cdnLatest represents CDN latest.json format // // { // "version": "1.0.0", // "released_at": "...", // "assets": { "darwin-arm64": "https://...", ... } // } type cdnLatest struct { Version string `json:"version"` ReleasedAt string `json:"released_at"` Assets map[string]string `json:"assets"` } // checkResult is the JSON payload emitted by `yao upgrade --check` type checkResult struct { Current string `json:"current"` Latest string `json:"latest"` UpdateAvailable bool `json:"update_available"` DownloadURL string `json:"download_url,omitempty"` Source string `json:"source"` } 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) { latestVersion, downloadURL, err := resolveLatest() if err != nil { if upgradeCheck { emitCheckJSON(checkResult{ Current: share.VERSION, Source: upgradeSource, }, err) os.Exit(1) } fmt.Println(color.RedString(L("Fatal: %s"), err.Error())) os.Exit(1) } updateAvailable := compareVersions(latestVersion, share.VERSION) > 0 if upgradeCheck { emitCheckJSON(checkResult{ Current: share.VERSION, Latest: latestVersion, UpdateAvailable: updateAvailable, DownloadURL: downloadURL, Source: upgradeSource, }, nil) return } fmt.Printf("%s %s\n", color.WhiteString(L("Current version:")), color.CyanString(share.VERSION)) fmt.Printf("%s %s\n", color.WhiteString(L("Latest version: ")), color.GreenString(latestVersion)) if !updateAvailable { fmt.Println(color.GreenString(L("🎉Current version is the latest🎉"))) return } if downloadURL == "" { fmt.Println(color.RedString(L("Fatal: %s"), fmt.Sprintf("asset not found for %s-%s", runtime.GOOS, runtime.GOARCH))) os.Exit(1) } if !upgradeYes { 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(downloadURL)) if err := downloadAndReplace(downloadURL, 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)) }, } // resolveLatest fetches latest version info. Priority: // 1. --source flag (explicit CDN URL) // 2. GitHub Releases API (default) // 3. Fallback to get.yaoapps.com CDN if GitHub fails (for users in China) func resolveLatest() (string, string, error) { if upgradeSource != "" { return resolveFromCDN(upgradeSource) } ver, url, err := resolveFromGitHub() if err == nil { return ver, url, nil } fmt.Println(color.YellowString(L("GitHub unavailable (%s), trying CDN fallback..."), err.Error())) return resolveFromCDN(cdnFallbackBase) } // resolveFromCDN fetches latest.json from the given CDN base URL. // The URL should point to the directory containing latest.json, // e.g. "https://get.yaoapps.com/yao". func resolveFromCDN(base string) (string, string, error) { url := strings.TrimRight(base, "/") + "/latest.json" client := &http.Client{Timeout: 30 * time.Second} req, err := http.NewRequest("GET", url, nil) if err != nil { return "", "", err } req.Header.Set("User-Agent", fmt.Sprintf("yao/%s", share.VERSION)) resp, err := client.Do(req) if err != nil { return "", "", fmt.Errorf("fetch CDN latest.json failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", "", fmt.Errorf("CDN returned status %d for %s", resp.StatusCode, url) } var data cdnLatest if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return "", "", fmt.Errorf("parse latest.json failed: %w", err) } key := fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) dl := data.Assets[key] return strings.TrimPrefix(data.Version, "v"), dl, nil } // resolveFromGitHub keeps the original GitHub Releases API behavior. func resolveFromGitHub() (string, string, error) { release, err := fetchLatestRelease() if err != nil { return "", "", err } latestVersion := strings.TrimPrefix(release.TagName, "v") assetName := buildAssetName(latestVersion) asset := findAsset(release.Assets, assetName) if asset == nil { return latestVersion, "", nil } return latestVersion, asset.BrowserDownloadURL, nil } // compareVersions returns >0 if a > b, 0 if equal, <0 if a < b. // Uses dotted numeric comparison; falls back to string comparison for non-numeric parts. func compareVersions(a, b string) int { a = strings.TrimPrefix(a, "v") b = strings.TrimPrefix(b, "v") if a == b { return 0 } // Separate pre-release suffix (after '-') baseA, preA := splitVersion(a) baseB, preB := splitVersion(b) partsA := strings.Split(baseA, ".") partsB := strings.Split(baseB, ".") n := len(partsA) if len(partsB) > n { n = len(partsB) } for i := 0; i < n; i++ { var pa, pb string if i < len(partsA) { pa = partsA[i] } if i < len(partsB) { pb = partsB[i] } if c := compareNumeric(pa, pb); c != 0 { return c } } // Base parts equal: release version > pre-release version if preA == "" && preB != "" { return 1 } if preA != "" && preB == "" { return -1 } return strings.Compare(preA, preB) } func splitVersion(v string) (string, string) { if idx := strings.Index(v, "-"); idx >= 0 { return v[:idx], v[idx+1:] } return v, "" } func compareNumeric(a, b string) int { var na, nb int _, errA := fmt.Sscanf(a, "%d", &na) _, errB := fmt.Sscanf(b, "%d", &nb) if errA == nil && errB == nil { if na < nb { return -1 } if na > nb { return 1 } return 0 } return strings.Compare(a, b) } // emitCheckJSON writes the check result as a single-line JSON to stdout. func emitCheckJSON(r checkResult, err error) { if err != nil { payload := map[string]interface{}{ "current": r.Current, "source": r.Source, "error": err.Error(), } b, _ := json.Marshal(payload) fmt.Println(string(b)) return } b, _ := json.Marshal(r) fmt.Println(string(b)) } // 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 } func init() { upgradeCmd.Flags().BoolVarP(&upgradeYes, "yes", "y", false, L("Skip interactive confirmation")) upgradeCmd.Flags().BoolVar(&upgradeCheck, "check", false, L("Only check for updates and print JSON result")) upgradeCmd.Flags().StringVar(&upgradeSource, "source", "", L("Custom download source URL (e.g. https://get.yaoapps.com/yao)")) }