package cli import ( "archive/tar" "compress/gzip" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" ) // GitHubRelease is the subset of the GitHub releases API response we need. type GitHubRelease struct { TagName string `json:"tag_name"` HTMLURL string `json:"html_url"` } // FetchLatestRelease fetches the latest release tag from the multica GitHub repo. func FetchLatestRelease() (*GitHubRelease, error) { client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/multica-ai/multica/releases/latest", nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/vnd.github+json") 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 %d", resp.StatusCode) } var release GitHubRelease if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return nil, err } return &release, nil } // IsBrewInstall checks whether the running multica binary was installed via Homebrew. func IsBrewInstall() bool { exePath, err := os.Executable() if err != nil { return false } resolved, err := filepath.EvalSymlinks(exePath) if err != nil { resolved = exePath } brewPrefix := GetBrewPrefix() if brewPrefix != "" && strings.HasPrefix(resolved, brewPrefix) { return true } for _, prefix := range []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"} { if strings.HasPrefix(resolved, prefix+"/Cellar/") { return true } } return false } // GetBrewPrefix returns the Homebrew prefix by running `brew --prefix`, or empty string. func GetBrewPrefix() string { out, err := exec.Command("brew", "--prefix").Output() if err != nil { return "" } return strings.TrimSpace(string(out)) } // UpdateViaBrew runs `brew upgrade multica-ai/tap/multica`. // Returns the combined output and any error. func UpdateViaBrew() (string, error) { cmd := exec.Command("brew", "upgrade", "multica-ai/tap/multica") out, err := cmd.CombinedOutput() if err != nil { return string(out), fmt.Errorf("brew upgrade failed: %w", err) } return string(out), nil } // UpdateViaDownload downloads the latest release binary from GitHub and replaces // the current executable in-place. Returns the combined output message and any error. func UpdateViaDownload(targetVersion string) (string, error) { // Determine current binary path. exePath, err := os.Executable() if err != nil { return "", fmt.Errorf("resolve executable path: %w", err) } exePath, err = filepath.EvalSymlinks(exePath) if err != nil { return "", fmt.Errorf("resolve symlink: %w", err) } // Build download URL: multica_{os}_{arch}.tar.gz tag := targetVersion if !strings.HasPrefix(tag, "v") { tag = "v" + tag } assetName := fmt.Sprintf("multica_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH) downloadURL := fmt.Sprintf("https://github.com/multica-ai/multica/releases/download/%s/%s", tag, assetName) // Download the tarball. client := &http.Client{Timeout: 120 * time.Second} resp, err := client.Get(downloadURL) if err != nil { return "", fmt.Errorf("download failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download failed: HTTP %d from %s", resp.StatusCode, downloadURL) } // Extract the "multica" binary from the tarball. binaryData, err := extractBinaryFromTarGz(resp.Body, "multica") if err != nil { return "", fmt.Errorf("extract binary: %w", err) } // Atomic replace: write to temp file, then rename over the original. dir := filepath.Dir(exePath) tmpFile, err := os.CreateTemp(dir, "multica-update-*") if err != nil { return "", fmt.Errorf("create temp file: %w", err) } tmpPath := tmpFile.Name() if _, err := tmpFile.Write(binaryData); err != nil { tmpFile.Close() os.Remove(tmpPath) return "", fmt.Errorf("write temp file: %w", err) } tmpFile.Close() // Preserve original file permissions. info, err := os.Stat(exePath) if err != nil { os.Remove(tmpPath) return "", fmt.Errorf("stat original binary: %w", err) } if err := os.Chmod(tmpPath, info.Mode()); err != nil { os.Remove(tmpPath) return "", fmt.Errorf("chmod temp file: %w", err) } // Replace the original binary. if err := os.Rename(tmpPath, exePath); err != nil { os.Remove(tmpPath) return "", fmt.Errorf("replace binary: %w", err) } return fmt.Sprintf("Downloaded %s and replaced %s", assetName, exePath), nil } // extractBinaryFromTarGz reads a .tar.gz stream and returns the contents of the // named file entry. func extractBinaryFromTarGz(r io.Reader, name string) ([]byte, error) { gz, err := gzip.NewReader(r) if err != nil { return nil, fmt.Errorf("gzip reader: %w", err) } defer gz.Close() tr := tar.NewReader(gz) for { hdr, err := tr.Next() if err == io.EOF { return nil, fmt.Errorf("binary %q not found in archive", name) } if err != nil { return nil, fmt.Errorf("read tar: %w", err) } // Match the binary name (may be prefixed with a directory). if filepath.Base(hdr.Name) == name && hdr.Typeflag == tar.TypeReg { data, err := io.ReadAll(tr) if err != nil { return nil, fmt.Errorf("read binary: %w", err) } return data, nil } } }