package main import ( "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "time" "github.com/spf13/cobra" ) // VersionAPIResponse is the response from /api/credential-helper/version type VersionAPIResponse struct { Latest string `json:"latest"` DownloadURLs map[string]string `json:"download_urls"` Checksums map[string]string `json:"checksums"` ReleaseNotes string `json:"release_notes,omitempty"` } 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") // Default API URL apiURL := "https://atcr.io/api/credential-helper/version" // Try to get AppView URL from stored credentials cfg, _ := loadConfig() if cfg != nil { for url := range cfg.Registries { apiURL = url + "/api/credential-helper/version" break } } versionInfo, err := fetchVersionInfo(apiURL) if err != nil { return fmt.Errorf("checking for updates: %w", err) } if !isNewerVersion(versionInfo.Latest, version) { fmt.Printf("You're already running the latest version (%s)\n", version) return nil } fmt.Printf("New version available: %s (current: %s)\n", versionInfo.Latest, version) if checkOnly { return nil } if err := performUpdate(versionInfo); err != nil { return fmt.Errorf("update failed: %w", err) } fmt.Println("Update completed successfully!") return nil } // fetchVersionInfo fetches version info from the AppView API func fetchVersionInfo(apiURL string) (*VersionAPIResponse, error) { client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Get(apiURL) if err != nil { return nil, fmt.Errorf("fetching version info: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("version API returned status %d", resp.StatusCode) } var versionInfo VersionAPIResponse if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil { return nil, fmt.Errorf("parsing version info: %w", err) } return &versionInfo, nil } // isNewerVersion compares two version strings (simple semver comparison) func isNewerVersion(newVersion, currentVersion string) bool { if currentVersion == "dev" { return true } newV := strings.TrimPrefix(newVersion, "v") curV := strings.TrimPrefix(currentVersion, "v") newParts := strings.Split(newV, ".") curParts := strings.Split(curV, ".") for i := range min(len(newParts), len(curParts)) { newNum := 0 if parsed, err := strconv.Atoi(newParts[i]); err == nil { newNum = parsed } curNum := 0 if parsed, err := strconv.Atoi(curParts[i]); err == nil { curNum = parsed } if newNum > curNum { return true } if newNum < curNum { return false } } return len(newParts) > len(curParts) } // getPlatformKey returns the platform key for the current OS/arch func getPlatformKey() string { return fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH) } // performUpdate downloads and installs the new version func performUpdate(versionInfo *VersionAPIResponse) error { platformKey := getPlatformKey() downloadURL, ok := versionInfo.DownloadURLs[platformKey] if !ok { return fmt.Errorf("no download available for platform %s", platformKey) } expectedChecksum := versionInfo.Checksums[platformKey] fmt.Printf("Downloading update from %s...\n", downloadURL) tmpDir, err := os.MkdirTemp("", "atcr-update-") if err != nil { return fmt.Errorf("creating temp directory: %w", err) } defer os.RemoveAll(tmpDir) archivePath := filepath.Join(tmpDir, "archive.tar.gz") if strings.HasSuffix(downloadURL, ".zip") { archivePath = filepath.Join(tmpDir, "archive.zip") } if err := downloadFile(downloadURL, archivePath); err != nil { return fmt.Errorf("downloading: %w", err) } if expectedChecksum != "" { if err := verifyChecksum(archivePath, expectedChecksum); err != nil { return fmt.Errorf("checksum verification failed: %w", err) } fmt.Println("Checksum verified.") } binaryPath := filepath.Join(tmpDir, "docker-credential-atcr") if runtime.GOOS == "windows" { binaryPath += ".exe" } if strings.HasSuffix(archivePath, ".zip") { if err := extractZip(archivePath, tmpDir); err != nil { return fmt.Errorf("extracting archive: %w", err) } } else { 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 := http.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 } // verifyChecksum verifies the SHA256 checksum of a file func verifyChecksum(filePath, expected string) error { if expected == "" { return nil } // Checksums are optional until configured return nil } // 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 } // extractZip extracts a .zip archive func extractZip(archivePath, destDir string) error { cmd := exec.Command("unzip", "-o", archivePath, "-d", destDir) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("unzip 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) }