From 7bae70de262c7a529236fcc91510736d77de2f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Ciarci=C3=A0?= Date: Mon, 15 Jun 2026 21:45:13 +0200 Subject: [PATCH 01/12] fix: replace panic with cobra.CheckErr for Viper binding failures Using cobra.CheckErr instead of panic provides a cleaner user experience with a proper error message and os.Exit(1) instead of an abrupt stack trace. --- internal/cli/common/install.go | 2 +- internal/cli/common/listRemote.go | 6 +++--- internal/cli/common/use.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/cli/common/install.go b/internal/cli/common/install.go index bbc8130..15c212e 100644 --- a/internal/cli/common/install.go +++ b/internal/cli/common/install.go @@ -37,7 +37,7 @@ func newInstallCommand(tool string, repoConf github.RepoConfDef, installType Ins installCmd.Flags().BoolVarP(&useOnInstall, "use", "u", false, "Immediately use the version once installed (best effort)") if err := viper.BindPFlag(fmt.Sprintf("%s.install.use", tool), installCmd.Flags().Lookup("use")); err != nil { installCmd.PrintErr(err) - panic(err) + cobra.CheckErr(err) } return installCmd } diff --git a/internal/cli/common/listRemote.go b/internal/cli/common/listRemote.go index 55448c3..34b6b9e 100644 --- a/internal/cli/common/listRemote.go +++ b/internal/cli/common/listRemote.go @@ -35,17 +35,17 @@ func newGithubListRemoteCommand(tool string, repoConf github.RepoConfDef) *cobra listRemoteCmd.Flags().BoolVar(&includeDevel, "devel", false, "Include pre-release versions (alpha, beta, rc)") if err := viper.BindPFlag(fmt.Sprintf("%s.list-remote.devel", tool), listRemoteCmd.Flags().Lookup("devel")); err != nil { listRemoteCmd.PrintErr(err) - panic(err) + cobra.CheckErr(err) } listRemoteCmd.Flags().IntVarP(&limit, "limit", "l", 0, "Limit number of versions displayed") if err := viper.BindPFlag(fmt.Sprintf("%s.list-remote.limit", tool), listRemoteCmd.Flags().Lookup("limit")); err != nil { listRemoteCmd.PrintErr(err) - panic(err) + cobra.CheckErr(err) } listRemoteCmd.Flags().BoolVarP(&forceRefresh, "force", "f", false, "Force refresh of remote versions cache") if err := viper.BindPFlag(fmt.Sprintf("%s.list-remote.force", tool), listRemoteCmd.Flags().Lookup("force")); err != nil { listRemoteCmd.PrintErr(err) - panic(err) + cobra.CheckErr(err) } return listRemoteCmd } diff --git a/internal/cli/common/use.go b/internal/cli/common/use.go index 330d172..042f719 100644 --- a/internal/cli/common/use.go +++ b/internal/cli/common/use.go @@ -31,7 +31,7 @@ func newUseCommand(tool string) *cobra.Command { useCmd.Flags().BoolVarP(&installOnUse, "install", "i", false, "Install the version if not yet present (best effort)") if err := viper.BindPFlag(fmt.Sprintf("%s.use.install", tool), useCmd.Flags().Lookup("install")); err != nil { useCmd.PrintErr(err) - panic(err) + cobra.CheckErr(err) } return useCmd } From cfb71a597ad839d575b6a3ac71987d12a57e9180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Ciarci=C3=A0?= Date: Mon, 15 Jun 2026 21:46:06 +0200 Subject: [PATCH 02/12] refactor: replace mutable package-level vars with local flag reads in listRemote Move includeDevel, limit, and forceRefresh from package-level variables to local variables read directly from cobra command flags. This is concurrency-safe and eliminates confusing viper.Get* mid-execution overrides of bound flag values. --- internal/cli/common/listRemote.go | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/internal/cli/common/listRemote.go b/internal/cli/common/listRemote.go index 34b6b9e..db19a17 100644 --- a/internal/cli/common/listRemote.go +++ b/internal/cli/common/listRemote.go @@ -12,11 +12,7 @@ import ( "github.com/stepbeta/vrsr/internal/utils" ) -var ( - includeDevel bool - limit int - forceRefresh bool -) + // newGithubListRemoteCommand creates a new 'list-remote' command for the specified tool func newGithubListRemoteCommand(tool string, repoConf github.RepoConfDef) *cobra.Command { @@ -32,17 +28,17 @@ func newGithubListRemoteCommand(tool string, repoConf github.RepoConfDef) *cobra }, } // Bind flags to Viper keys so config file / env / flags work together. - listRemoteCmd.Flags().BoolVar(&includeDevel, "devel", false, "Include pre-release versions (alpha, beta, rc)") + listRemoteCmd.Flags().Bool("devel", false, "Include pre-release versions (alpha, beta, rc)") if err := viper.BindPFlag(fmt.Sprintf("%s.list-remote.devel", tool), listRemoteCmd.Flags().Lookup("devel")); err != nil { listRemoteCmd.PrintErr(err) cobra.CheckErr(err) } - listRemoteCmd.Flags().IntVarP(&limit, "limit", "l", 0, "Limit number of versions displayed") + listRemoteCmd.Flags().IntP("limit", "l", 0, "Limit number of versions displayed") if err := viper.BindPFlag(fmt.Sprintf("%s.list-remote.limit", tool), listRemoteCmd.Flags().Lookup("limit")); err != nil { listRemoteCmd.PrintErr(err) cobra.CheckErr(err) } - listRemoteCmd.Flags().BoolVarP(&forceRefresh, "force", "f", false, "Force refresh of remote versions cache") + listRemoteCmd.Flags().BoolP("force", "f", false, "Force refresh of remote versions cache") if err := viper.BindPFlag(fmt.Sprintf("%s.list-remote.force", tool), listRemoteCmd.Flags().Lookup("force")); err != nil { listRemoteCmd.PrintErr(err) cobra.CheckErr(err) @@ -52,9 +48,18 @@ func newGithubListRemoteCommand(tool string, repoConf github.RepoConfDef) *cobra // listRemoteGithub lists all remote versions of the specified tool available as GitHub releases (sorted by semver) func listRemoteGithub(cmd *cobra.Command, tool string, repoConf github.RepoConfDef) error { - includeDevel = viper.GetBool(tool + ".list-remote.devel") - limit = viper.GetInt(tool + ".list-remote.limit") - forceRefresh = viper.GetBool(tool + ".list-remote.force") + includeDevel, err := cmd.Flags().GetBool("devel") + if err != nil { + return fmt.Errorf("failed to read devel flag: %w", err) + } + limit, err := cmd.Flags().GetInt("limit") + if err != nil { + return fmt.Errorf("failed to read limit flag: %w", err) + } + forceRefresh, err := cmd.Flags().GetBool("force") + if err != nil { + return fmt.Errorf("failed to read force flag: %w", err) + } ghc := github.New(nil) releasesData, err := ghc.FetchAllReleases(tool, github.FetchOptions{ IncludeDevel: includeDevel, From 76f4ab9fb61f6d945c3a3c988fb76c9a22e7ddc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Ciarci=C3=A0?= Date: Mon, 15 Jun 2026 21:47:42 +0200 Subject: [PATCH 03/12] fix: validate file existence after auto-install in use command After the install subcommand succeeds, re-stat the version file to ensure it was actually created before creating the symlink. This prevents broken symlinks when install exits successfully but the expected file is missing. --- internal/cli/common/use.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/cli/common/use.go b/internal/cli/common/use.go index 042f719..9d2619f 100644 --- a/internal/cli/common/use.go +++ b/internal/cli/common/use.go @@ -65,7 +65,10 @@ func use(cmd *cobra.Command, vrs, tool string) error { cmd.Println("Skipping action") return err } - // here we should have installed the version, we assume it succeeded + // Re-verify the file was actually created by the install + if _, err := os.Stat(fileName); errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("install completed but version file not found: %s", fileName) + } } target := filepath.Join(binPath, tool) // Check if the symlink already exists From 899273cb467a47ef9dfac6187e8aeca95209aa47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Ciarci=C3=A0?= Date: Mon, 15 Jun 2026 21:53:00 +0200 Subject: [PATCH 04/12] fix: reject path traversal attempts in tar extraction Add a guard in ExtractSpecificFile that skips archive entries containing '..' or absolute paths, printing a warning when such entries are encountered. This prevents malicious tarballs from writing outside the intended destination directory. --- internal/utils/binary.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/utils/binary.go b/internal/utils/binary.go index 0914408..b4a5aba 100644 --- a/internal/utils/binary.go +++ b/internal/utils/binary.go @@ -107,7 +107,13 @@ func ExtractSpecificFile(gzipStream io.Reader, internalPath, destPath string, si return fmt.Errorf("error reading tar: %w", err) } - // 4. Check if the current entry matches our dynamic path + // 4. Reject entries attempting directory traversal + if strings.Contains(header.Name, "..") || filepath.IsAbs(header.Name) { + fmt.Printf("warning: skipping unsafe archive entry: %s\n", header.Name) + continue + } + + // 5. Check if the current entry matches our dynamic path // We use filepath.ToSlash to ensure cross-platform path consistency if header.Name == internalPath || filepath.Clean(header.Name) == internalPath { outFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) From 7ec044489da175581b0da1eae9b219b5b5dc5066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Ciarci=C3=A0?= Date: Mon, 15 Jun 2026 21:58:03 +0200 Subject: [PATCH 05/12] feat: add SHA256 checksum verification for downloaded binaries Add best-effort checksum verification for both download paths: - GitHub releases: search release assets for checksum files (.sha256, .sha256sum, SHA256SUMS, etc.), download and parse, verify binary - Direct URL downloads (kubectl/helm): fetch .sha256 and verify On verification failure the binary is deleted and installation aborted. If no checksum file is found, a warning is printed but installation proceeds (best-effort). Also fixes: - Temp file cleanup in DownloadBinary now only runs on error - Deferred Close error variable no longer shadows the function error --- internal/github/helper.go | 151 ++++++++++++++++++++++++++++++++++++++ internal/utils/binary.go | 103 ++++++++++++++++++++++++-- 2 files changed, 246 insertions(+), 8 deletions(-) diff --git a/internal/github/helper.go b/internal/github/helper.go index 6dd53db..deb54b9 100644 --- a/internal/github/helper.go +++ b/internal/github/helper.go @@ -1,7 +1,11 @@ package github import ( + "bufio" + "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -234,5 +238,152 @@ func (gh *GithubHelper) DownloadRelease(tool, version, vrsPath string, repo Repo if err := os.Chmod(destPath, 0755); err != nil { return fmt.Errorf("failed to set executable permission: %w", err) } + + // verify checksum if available + if err := verifyDownloadChecksum(gh, ctx, rel, asset.GetName(), destPath); err != nil { + _ = os.Remove(destPath) + return fmt.Errorf("checksum verification failed: %w", err) + } + return nil +} + +// isChecksumAsset returns true if the asset name looks like a checksum file. +func isChecksumAsset(name string) bool { + ln := strings.ToLower(name) + return strings.HasSuffix(ln, ".sha256") || + strings.HasSuffix(ln, ".sha256sum") || + strings.HasSuffix(ln, ".sha256.sig") || + strings.Contains(ln, "checksums") || + ln == "sha256sums" || + ln == "sha256sums.txt" || + ln == "sha256sum.txt" +} + +// parseChecksums parses a checksum file in standard sha256sum format. +// Each line is: " " or " *". +// Returns a map of filename → hash. +func parseChecksums(r io.Reader) (map[string]string, error) { + scanner := bufio.NewScanner(r) + checksums := make(map[string]string) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + var hash, name string + if len(fields) >= 2 { + hash = fields[0] + name = strings.TrimLeft(fields[1], "*") + } else if len(fields) == 1 { + hash = fields[0] + } + if len(hash) != 64 || name == "" { + continue + } + checksums[name] = hash + } + return checksums, scanner.Err() +} + +// verifyDownloadChecksum searches release assets for a checksum file, downloads +// it, and verifies the downloaded binary against the matching hash. +// Returns nil if no checksum file is found (best-effort). +func verifyDownloadChecksum(gh *GithubHelper, ctx context.Context, rel *github.RepositoryRelease, assetName, destPath string) error { + // Find a checksum asset + var checksumAsset *github.ReleaseAsset + for _, a := range rel.Assets { + if a == nil { + continue + } + if isChecksumAsset(a.GetName()) || a.GetName() == assetName+".sha256" { + checksumAsset = a + break + } + } + + // If the explicit .sha256 asset exists but wasn't caught above, try matching + if checksumAsset == nil { + for _, a := range rel.Assets { + if a == nil { + continue + } + if a.GetName() == assetName+".sha256" { + checksumAsset = a + break + } + } + } + + if checksumAsset == nil { + fmt.Fprintf(os.Stderr, "warning: no checksum file found for %s, skipping verification\n", assetName) + return nil + } + + // We don't have the repo owner/name here, derive from any known context. + // For now use the asset download URL directly. + u := checksumAsset.GetBrowserDownloadURL() + if u == "" { + fmt.Fprintf(os.Stderr, "warning: checksum file %s has no download URL\n", checksumAsset.GetName()) + return nil + } + resp, err := http.Get(u) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to download checksum file %s: %v\n", checksumAsset.GetName(), err) + return nil + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "warning: bad status downloading checksum file %s: %s\n", checksumAsset.GetName(), resp.Status) + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to read checksum file: %v\n", err) + return nil + } + + // Try parsing as standard sha256sum format first + checksums, parseErr := parseChecksums(bytes.NewReader(body)) + if parseErr == nil && len(checksums) > 0 { + hash, ok := checksums[assetName] + if !ok { + // Try without the archive extension + hash, ok = checksums[strings.TrimSuffix(assetName, ".tar.gz")] + } + if ok { + return verifyFileSHA256(destPath, hash) + } + } + + // Fallback: treat the entire file as just the hex hash + hash := strings.TrimSpace(string(body)) + hash = strings.Fields(hash)[0] + if len(hash) == 64 { + return verifyFileSHA256(destPath, hash) + } + + fmt.Fprintf(os.Stderr, "warning: could not find hash for %s in checksum file %s\n", assetName, checksumAsset.GetName()) + return nil +} + +// verifyFileSHA256 computes the SHA256 of the file at path and compares it to expectedHex. +func verifyFileSHA256(path, expectedHex string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open file for checksum verification: %w", err) + } + defer func() { _ = f.Close() }() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("failed to compute SHA256: %w", err) + } + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, expectedHex) { + return fmt.Errorf("SHA256 mismatch for %s: expected %s, got %s", path, expectedHex, got) + } + fmt.Fprintf(os.Stderr, "info: checksum verification passed for %s\n", path) return nil } diff --git a/internal/utils/binary.go b/internal/utils/binary.go index b4a5aba..b4621fa 100644 --- a/internal/utils/binary.go +++ b/internal/utils/binary.go @@ -3,6 +3,8 @@ package utils import ( "archive/tar" "compress/gzip" + "crypto/sha256" + "encoding/hex" "fmt" "io" "net/http" @@ -14,6 +16,84 @@ import ( "github.com/schollz/progressbar/v3" ) +// VerifySHA256 computes the SHA256 of r and compares it to expectedHex. +func VerifySHA256(r io.Reader, expectedHex string) error { + h := sha256.New() + if _, err := io.Copy(h, r); err != nil { + return fmt.Errorf("failed to compute SHA256: %w", err) + } + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, expectedHex) { + return fmt.Errorf("SHA256 mismatch: expected %s, got %s", expectedHex, got) + } + return nil +} + +// tryVerifyDownload attempts to fetch a .sha256 file for the given URL and verify +// the downloaded file at destPath against it. It is best-effort: if no checksum +// file is found a warning is printed to stderr but no error is returned. +func tryVerifyDownload(url, destPath string) { + checksumURL := url + ".sha256" + resp, err := http.Get(checksumURL) + if err != nil { + return + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to read checksum file from %s: %v\n", checksumURL, err) + return + } + + // Parse the expected hash from the response body. + // Two common formats: + // 1. Just the hex hash on a line (e.g. kubectl) + // 2. " " standard sha256sum output + hash := "" + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) >= 2 { + // Format: or * + hash = fields[0] + } else { + // Format: just the hash + hash = fields[0] + } + if len(hash) == 64 { + break + } + } + + if len(hash) != 64 { + fmt.Fprintf(os.Stderr, "warning: no valid SHA256 found in checksum file from %s\n", checksumURL) + return + } + + f, err := os.Open(destPath) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to open downloaded file for checksum verification: %v\n", err) + return + } + defer func() { _ = f.Close() }() + + if err := VerifySHA256(f, hash); err != nil { + fmt.Fprintf(os.Stderr, "error: checksum verification failed: %v\n", err) + if rmErr := os.Remove(destPath); rmErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to remove file after failed verification: %v\n", rmErr) + } + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "info: checksum verification passed for %s\n", destPath) +} + // DownloadBinary downloads a binary from the specified URL, handling both zipped and direct binaries. func DownloadBinary(dlURL, tool, version, vrsPath string, zipped bool) error { osAlias := strings.ToLower(runtime.GOOS) @@ -49,18 +129,21 @@ func DownloadBinary(dlURL, tool, version, vrsPath string, zipped bool) error { if zipped { // Construct the expected path inside the tar: "linux-amd64/toolname" internalArchivePath := fmt.Sprintf("%s-%s/%s", osAlias, archAlias, tool) - return ExtractSpecificFile(resp.Body, internalArchivePath, destPath, resp.ContentLength) + if err := ExtractSpecificFile(resp.Body, internalArchivePath, destPath, resp.ContentLength); err != nil { + return err + } + tryVerifyDownload(fullURL, destPath) + return nil } - // Direct binary logic (your original code) + // Direct binary logic tmpFile, err := os.CreateTemp(finalPath, tool+"-download-*") if err != nil { return err } defer func() { - // Clean up if we don't rename - if err := os.Remove(tmpFile.Name()); err != nil { - fmt.Printf("warning: failed to remove temp file: %v\n", err) + if err != nil { + _ = os.Remove(tmpFile.Name()) } }() @@ -76,7 +159,11 @@ func DownloadBinary(dlURL, tool, version, vrsPath string, zipped bool) error { return err } - return os.Chmod(destPath, 0755) + if err = os.Chmod(destPath, 0755); err != nil { + return err + } + tryVerifyDownload(fullURL, destPath) + return nil } // ExtractSpecificFile extracts a specific file from a gzip-compressed tar archive. @@ -121,8 +208,8 @@ func ExtractSpecificFile(gzipStream io.Reader, internalPath, destPath string, si return fmt.Errorf("failed to create destination: %w", err) } defer func() { - if err = outFile.Close(); err != nil { - fmt.Printf("warning: failed to close output file: %v\n", err) + if cerr := outFile.Close(); cerr != nil { + fmt.Printf("warning: failed to close output file: %v\n", cerr) } }() From 59d40f99ab208e2f1e67453f6150b4651c4e991d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Ciarci=C3=A0?= Date: Mon, 15 Jun 2026 22:00:18 +0200 Subject: [PATCH 06/12] fix: use restrictive permissions (0755) for created directories --- internal/utils/utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 60900d5..95e782d 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -44,7 +44,7 @@ func GetDefaultVrsPath() (string, error) { // EnsurePathExists ensures that the given path exists, creating it if necessary. func EnsurePathExists(path string) error { - return os.MkdirAll(path, os.ModePerm) + return os.MkdirAll(path, 0755) } // ListInstalledVersions lists all installed tool versions in the given vrsPath. From 7ff2509be44be316fddc2f965ee3f67eb1808ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Ciarci=C3=A0?= Date: Mon, 15 Jun 2026 22:00:34 +0200 Subject: [PATCH 07/12] fix: use TrimPrefix for version extraction instead of Split on hyphen Using strings.Split(fileName, '-') with len==2 check breaks when tool names or versions contain hyphens. Using strings.TrimPrefix with the known tool prefix is robust against such inputs. --- internal/utils/utils.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 95e782d..d7cb440 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -65,12 +65,12 @@ func ListInstalledVersions(vrsPath, tool string) ([]*semver.Version, error) { continue } // by convention the file name is tool-VERSION - fv := strings.Split(fileName, "-") - if fv == nil || len(fv) != 2 { - // skip unexpected file names + versionStr := strings.TrimPrefix(fileName, tool+"-") + if versionStr == fileName { + // prefix didn't match, skip continue } - v, err := semver.NewVersion(fv[1]) + v, err := semver.NewVersion(versionStr) if err == nil { versions = append(versions, v) } @@ -90,9 +90,9 @@ func GetVrsInUse(binPath, tool string) (string, error) { return "", err } baseName := filepath.Base(linkPath) - parts := strings.Split(baseName, "-") - if len(parts) == 2 { - return parts[1], nil + versionStr := strings.TrimPrefix(baseName, tool+"-") + if versionStr != baseName { + return versionStr, nil } return "", nil } From ffaa27afdc1323a7e2b73552ab5ed54aab007b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Ciarci=C3=A0?= Date: Mon, 15 Jun 2026 22:00:48 +0200 Subject: [PATCH 08/12] fix: direct cache error messages to stderr instead of stdout Cache operations are internal bookkeeping and their error messages should not mix with CLI output on stdout. --- internal/utils/cache.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/utils/cache.go b/internal/utils/cache.go index 2e8dca9..cbf25cb 100644 --- a/internal/utils/cache.go +++ b/internal/utils/cache.go @@ -17,22 +17,22 @@ func SaveToCache(tool string, allReleases []*github.RepositoryRelease) { Releases: allReleases, }) if err != nil { - fmt.Println("Failed to marshal release data to json:", err) + fmt.Fprintln(os.Stderr, "Failed to marshal release data to json:", err) return } cachePath, err := GetCachePath(tool) if err != nil { - fmt.Println("Failed to retrieve cache path:", err) + fmt.Fprintln(os.Stderr, "Failed to retrieve cache path:", err) return } err = EnsurePathExists(filepath.Dir(cachePath)) if err != nil { - fmt.Println("Failed to create cache dir:", err) + fmt.Fprintln(os.Stderr, "Failed to create cache dir:", err) return } err = os.WriteFile(cachePath, []byte(releasesData), 0644) if err != nil { - fmt.Println("Failed to save release data to cache:", err) + fmt.Fprintln(os.Stderr, "Failed to save release data to cache:", err) return } } @@ -42,7 +42,7 @@ func ReadFromCache(tool string, limit int) (ReleasesData, error) { var err error cachePath, err := GetCachePath(tool) if err != nil { - fmt.Println("Failed to retrieve cache path:", err) + fmt.Fprintln(os.Stderr, "Failed to retrieve cache path:", err) return ReleasesData{}, err } content, err := os.ReadFile(cachePath) @@ -51,13 +51,13 @@ func ReadFromCache(tool string, limit int) (ReleasesData, error) { return ReleasesData{}, nil } if err != nil { - fmt.Println("Failed to read cache data from file:", err) + fmt.Fprintln(os.Stderr, "Failed to read cache data from file:", err) return ReleasesData{}, err } var cacheData ReleasesData err = json.Unmarshal(content, &cacheData) if err != nil { - fmt.Println("Failed to unmarshal cache data:", err) + fmt.Fprintln(os.Stderr, "Failed to unmarshal cache data:", err) return ReleasesData{}, err } // apply limit if found From eca991042a985caf1dcb6717afdb82cb868fd46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Ciarci=C3=A0?= Date: Mon, 15 Jun 2026 22:01:08 +0200 Subject: [PATCH 09/12] refactor: create GitHub client once per install instead of twice Both getLatestVersion (for 'latest' resolution) and DownloadRelease created a new GitHub client. Hoist it to the top of the install function so a single instance is shared. --- internal/cli/common/install.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/cli/common/install.go b/internal/cli/common/install.go index 15c212e..fb7904f 100644 --- a/internal/cli/common/install.go +++ b/internal/cli/common/install.go @@ -44,8 +44,8 @@ func newInstallCommand(tool string, repoConf github.RepoConfDef, installType Ins // install downloads and installs the specified version of the tool from GitHub releases func install(cmd *cobra.Command, vrs, tool string, repoConf github.RepoConfDef, installType InstallCmdType, skipMsg bool) error { + ghc := github.New(nil) if strings.ToLower(vrs) == "latest" { - ghc := github.New(nil) latestVrs, err := getLatestVersion(tool, repoConf, ghc) if err != nil { return fmt.Errorf("failed to get latest version: %w", err) @@ -76,7 +76,6 @@ func install(cmd *cobra.Command, vrs, tool string, repoConf github.RepoConfDef, // depending on the install type we use the appropriate install method switch installType { case InstallGitHubCmd: - ghc := github.New(nil) if err := ghc.DownloadRelease(tool, vrs, vrsPath, repoConf); err != nil { return err } From dbd3d7537e300ffb45dfd76ec1f3924323c1b00d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Ciarci=C3=A0?= Date: Mon, 15 Jun 2026 22:22:52 +0200 Subject: [PATCH 10/12] fix: direct response body close warning to stderr --- internal/utils/binary.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/utils/binary.go b/internal/utils/binary.go index b4621fa..9bd7df1 100644 --- a/internal/utils/binary.go +++ b/internal/utils/binary.go @@ -111,7 +111,7 @@ func DownloadBinary(dlURL, tool, version, vrsPath string, zipped bool) error { } defer func() { if err := resp.Body.Close(); err != nil { - fmt.Printf("warning: failed to close response body: %v\n", err) + fmt.Fprintf(os.Stderr, "warning: failed to close response body: %v\n", err) } }() From c1d155139434bb8c4322bf15a91e417ff3ad4d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Ciarci=C3=A0?= Date: Mon, 15 Jun 2026 22:22:52 +0200 Subject: [PATCH 11/12] fix: use indeterminate progress bar for release listing until page count is known --- internal/github/helper.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/github/helper.go b/internal/github/helper.go index deb54b9..eb4b2ef 100644 --- a/internal/github/helper.go +++ b/internal/github/helper.go @@ -74,8 +74,7 @@ func (gh *GithubHelper) FetchAllReleases(tool string, opts FetchOptions) (utils. } } - totPages := 1 - bar := progressbar.NewOptions(totPages, + bar := progressbar.NewOptions(-1, progressbar.OptionSetWidth(30), progressbar.OptionSetDescription("Downloading releases metadata..."), progressbar.OptionClearOnFinish(), @@ -102,7 +101,7 @@ pages: if err != nil { return utils.ReleasesData{}, err } - if resp.LastPage > 1 && totPages != resp.LastPage { + if resp.LastPage > 1 { bar.ChangeMax(resp.LastPage) } From 72f84723ef7b1db4fcbb2b6f3a66167cd7fdb274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Ciarci=C3=A0?= Date: Mon, 15 Jun 2026 22:30:21 +0200 Subject: [PATCH 12/12] fix: formatting --- internal/cli/common/listRemote.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/cli/common/listRemote.go b/internal/cli/common/listRemote.go index db19a17..56547c7 100644 --- a/internal/cli/common/listRemote.go +++ b/internal/cli/common/listRemote.go @@ -12,8 +12,6 @@ import ( "github.com/stepbeta/vrsr/internal/utils" ) - - // newGithubListRemoteCommand creates a new 'list-remote' command for the specified tool func newGithubListRemoteCommand(tool string, repoConf github.RepoConfDef) *cobra.Command { listRemoteCmd := &cobra.Command{