package credhelper import ( "fmt" "io" "net/http" "os" "os/exec" "path" "path/filepath" "regexp" "runtime" "strconv" "strings" "time" "github.com/spf13/cobra" ) func newUpdateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "update", Short: "Update to the latest version", RunE: runUpdate, } cmd.Flags().Bool("check", false, "Only check for updates, don't install") return cmd } func runUpdate(cmd *cobra.Command, args []string) error { checkOnly, _ := cmd.Flags().GetBool("check") latest, err := fetchLatestVersion() if err != nil { return fmt.Errorf("checking for updates: %w", err) } if !isNewerVersion(latest, cfg.Version) { fmt.Printf("You're already running the latest version (%s)\n", cfg.Version) return nil } fmt.Printf("New version available: %s (current: %s)\n", latest, cfg.Version) if checkOnly { return nil } if err := performUpdate(latest); err != nil { return fmt.Errorf("update failed: %w", err) } fmt.Println("Update completed successfully!") return nil } // fetchLatestVersion resolves the latest released version by following the // {ReleasesBaseURL}/tags/latest redirect chain. Tangled redirects // DID→handle first, then handle/tags/latest→handle/tags/vX.Y.Z, so we follow // the chain and read the tag from the final effective URL. func fetchLatestVersion() (string, error) { client := httpClientWithTimeout(10*time.Second, nil) resp, err := client.Get(cfg.ReleasesBaseURL + "/tags/latest") if err != nil { return "", fmt.Errorf("fetching latest tag: %w", err) } defer resp.Body.Close() finalURL := resp.Request.URL tag := path.Base(finalURL.Path) if !strings.HasPrefix(tag, "v") { return "", fmt.Errorf("unexpected tag in final URL %q", finalURL.String()) } return tag, nil } // isNewerVersion compares two version strings (simple semver comparison) // describeSuffix matches the "--g" tail `git describe` appends to // the most recent tag, as in v0.1.4-18-g8f70cce. var describeSuffix = regexp.MustCompile(`^(.*)-(\d+)-g[0-9a-f]+$`) // parsedVersion is a version split into its numeric components plus, for a // git-describe build, how many commits it sits past its tag. type parsedVersion struct { nums []int ahead int } // parseVersion reads "v0.1.4" and "v0.1.4-18-g8f70cce". It reports ok=false for // anything it cannot read in full, so an unparseable version is never mistaken // for an upgrade — the previous code swallowed strconv errors and substituted // 0, which made every release look newer than any git-describe build. func parseVersion(s string) (parsedVersion, bool) { s = strings.TrimPrefix(strings.TrimSpace(s), "v") if s == "" { return parsedVersion{}, false } var ahead int if m := describeSuffix.FindStringSubmatch(s); m != nil { n, err := strconv.Atoi(m[2]) if err != nil { return parsedVersion{}, false } s, ahead = m[1], n } parts := strings.Split(s, ".") nums := make([]int, 0, len(parts)) for _, p := range parts { n, err := strconv.Atoi(p) if err != nil { return parsedVersion{}, false } nums = append(nums, n) } return parsedVersion{nums: nums, ahead: ahead}, true } // versionComponent reads the i'th numeric component, treating absent trailing // components as 0 so "0.1" and "0.1.0" compare equal. func versionComponent(nums []int, i int) int { if i < len(nums) { return nums[i] } return 0 } func isNewerVersion(newVersion, currentVersion string) bool { if currentVersion == "dev" { return true } newV, okNew := parseVersion(newVersion) curV, okCur := parseVersion(currentVersion) if !okNew || !okCur { return false } for i := range max(len(newV.nums), len(curV.nums)) { a, b := versionComponent(newV.nums, i), versionComponent(curV.nums, i) if a != b { return a > b } } // Same tag: whichever build sits further past it is the newer one. This is // deliberately not semver ordering, where a prerelease sorts below its // release — a git-describe build is commits AHEAD of its tag, not behind. return newV.ahead > curV.ahead } // goreleaserArchiveName returns the archive filename goreleaser publishes for // the given version and the current platform. The naming template lives in // .goreleaser.yaml: _{Version}_{Title(OS)}_{Arch} with // amd64→x86_64 and 386→i386. func goreleaserArchiveName(version string) string { versionNoV := strings.TrimPrefix(version, "v") os := strings.ToUpper(runtime.GOOS[:1]) + runtime.GOOS[1:] arch := runtime.GOARCH switch arch { case "amd64": arch = "x86_64" case "386": arch = "i386" } return fmt.Sprintf("%s_%s_%s_%s.tar.gz", cfg.UpdateAssetName, versionNoV, os, arch) } // performUpdate downloads and installs the new version func performUpdate(latest string) error { filename := goreleaserArchiveName(latest) downloadURL := fmt.Sprintf("%s/tags/%s/download/%s", cfg.ReleasesBaseURL, latest, filename) fmt.Printf("Downloading update from %s...\n", downloadURL) tmpDir, err := os.MkdirTemp("", cfg.BinaryName+"-update-") if err != nil { return fmt.Errorf("creating temp directory: %w", err) } defer os.RemoveAll(tmpDir) archivePath := filepath.Join(tmpDir, "archive.tar.gz") if err := downloadFile(downloadURL, archivePath); err != nil { return fmt.Errorf("downloading: %w", err) } binaryPath := filepath.Join(tmpDir, cfg.BinaryName) if runtime.GOOS == "windows" { binaryPath += ".exe" } if err := extractTarGz(archivePath, tmpDir); err != nil { return fmt.Errorf("extracting archive: %w", err) } currentPath, err := os.Executable() if err != nil { return fmt.Errorf("getting current executable path: %w", err) } currentPath, err = filepath.EvalSymlinks(currentPath) if err != nil { return fmt.Errorf("resolving symlinks: %w", err) } fmt.Println("Verifying new binary...") verifyCmd := exec.Command(binaryPath, "--version") if output, err := verifyCmd.Output(); err != nil { return fmt.Errorf("new binary verification failed: %w", err) } else { fmt.Printf("New binary version: %s", string(output)) } backupPath := currentPath + ".bak" if err := os.Rename(currentPath, backupPath); err != nil { return fmt.Errorf("backing up current binary: %w", err) } if err := copyFile(binaryPath, currentPath); err != nil { os.Rename(backupPath, currentPath) //nolint:errcheck return fmt.Errorf("installing new binary: %w", err) } if err := os.Chmod(currentPath, 0755); err != nil { os.Remove(currentPath) //nolint:errcheck os.Rename(backupPath, currentPath) //nolint:errcheck return fmt.Errorf("setting permissions: %w", err) } os.Remove(backupPath) //nolint:errcheck return nil } // downloadFile downloads a file from a URL to a local path func downloadFile(url, destPath string) error { resp, err := httpClient().Get(url) //nolint:gosec if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download returned status %d", resp.StatusCode) } out, err := os.Create(destPath) if err != nil { return err } defer out.Close() _, err = io.Copy(out, resp.Body) return err } // extractTarGz extracts a .tar.gz archive func extractTarGz(archivePath, destDir string) error { cmd := exec.Command("tar", "-xzf", archivePath, "-C", destDir) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("tar failed: %s: %w", string(output), err) } return nil } // copyFile copies a file from src to dst func copyFile(src, dst string) error { input, err := os.ReadFile(src) if err != nil { return err } return os.WriteFile(dst, input, 0755) }