* feat(daemon): support direct download update for non-Homebrew installs Previously, CLI auto-update only worked for Homebrew installations. Non-brew binaries would fail with "not installed via Homebrew". Now the daemon and `multica update` fall back to downloading the release binary directly from GitHub Releases when Homebrew is not detected. Also fixes: - Daemon restart now uses the current executable's absolute path instead of searching PATH, ensuring the updated binary is used - Brew installs preserve the symlink path so the new Cellar version is picked up - Daemon startup logs now include the CLI version - Update UI auto-clears "restarting" status after 5s to show the new version Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): remove dead DetectNewBinaryPath and guard against nil latest version Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
196 lines
5.2 KiB
Go
196 lines
5.2 KiB
Go
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
|
|
}
|
|
}
|
|
}
|
|
|