mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
* fix(daemon): use brew prefix symlink for self-restart so Linux Cellar deletion does not orphan runtimes After brew upgrade on Linux, os.Executable() resolves /proc/self/exe to the Cellar path (e.g. .../Cellar/multica/0.2.9/bin/multica), which brew cleanup deletes. The previous IsBrewInstall() short-circuit skipped EvalSymlinks to 'preserve' the symlink, but on Linux there was nothing to preserve - the path was already resolved. Use cli.GetBrewPrefix() to resolve the stable symlink path <brewPrefix>/bin/multica for brew installs. Fall back to EvalSymlinks(os.Executable()) with a warning log when GetBrewPrefix() returns empty (brew binary missing from PATH). Introduce package-level function vars (isBrewInstall, getBrewPrefix) so the daemon test can override them without modifying the cli package. Closes #1624 * fix(daemon): harden brew-prefix fallback and document the WHY When `brew --prefix` is unavailable but the binary is under a known Cellar root, recover the prefix from cli.MatchKnownBrewPrefix and target <prefix>/bin/multica instead of falling back to the resolved Cellar path (which brew cleanup just deleted). - Extract knownBrewPrefixes + MatchKnownBrewPrefix in cli/update.go and reuse from IsBrewInstall to keep one source of truth for the install-root list. - Add a WHY comment above the brew branch in triggerRestart explaining the /proc/self/exe -> Cellar -> deleted-by-brew-cleanup chain. - Cover both fallback paths (matched / unmatched) in daemon_test.go. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
341 lines
9.7 KiB
Go
341 lines
9.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const DefaultUpdateDownloadTimeout = 120 * time.Second
|
|
|
|
// 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"`
|
|
Assets []GitHubReleaseAsset `json:"assets"`
|
|
}
|
|
|
|
type GitHubReleaseAsset struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
}
|
|
|
|
func releaseArchiveExtension(goos string) string {
|
|
if goos == "windows" {
|
|
return "zip"
|
|
}
|
|
return "tar.gz"
|
|
}
|
|
|
|
func normalizeReleaseTag(targetVersion string) string {
|
|
tag := strings.TrimSpace(targetVersion)
|
|
if !strings.HasPrefix(tag, "v") {
|
|
tag = "v" + tag
|
|
}
|
|
return tag
|
|
}
|
|
|
|
func releaseAssetCandidates(targetVersion, goos, goarch string) []string {
|
|
tag := normalizeReleaseTag(targetVersion)
|
|
version := strings.TrimPrefix(tag, "v")
|
|
ext := releaseArchiveExtension(goos)
|
|
// Prefer the versioned name (current scheme); fall back to the legacy
|
|
// `multica_{os}_{arch}` name for releases that still ship it.
|
|
return []string{
|
|
fmt.Sprintf("multica-cli-%s-%s-%s.%s", version, goos, goarch, ext),
|
|
fmt.Sprintf("multica_%s_%s.%s", goos, goarch, ext),
|
|
}
|
|
}
|
|
|
|
func findReleaseAsset(assets []GitHubReleaseAsset, targetVersion, goos, goarch string) (*GitHubReleaseAsset, error) {
|
|
for _, candidate := range releaseAssetCandidates(targetVersion, goos, goarch) {
|
|
for i := range assets {
|
|
if assets[i].Name == candidate {
|
|
return &assets[i], nil
|
|
}
|
|
}
|
|
}
|
|
|
|
candidates := strings.Join(releaseAssetCandidates(targetVersion, goos, goarch), ", ")
|
|
return nil, fmt.Errorf("no matching release asset for %s/%s (tried: %s)", goos, goarch, candidates)
|
|
}
|
|
|
|
func fetchReleaseByTag(tag string) (*GitHubRelease, error) {
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/multica-ai/multica/releases/tags/"+tag, 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// knownBrewPrefixes lists the install roots Homebrew uses on each platform.
|
|
// Order is irrelevant — the prefixes do not nest.
|
|
var knownBrewPrefixes = []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"}
|
|
|
|
// MatchKnownBrewPrefix returns the Homebrew prefix whose Cellar contains path,
|
|
// or "" if path is not under a known Cellar. It is the offline equivalent of
|
|
// `brew --prefix`: callers reach for it when `brew --prefix` is unavailable
|
|
// (brew not on PATH) but the binary's path still betrays its install root.
|
|
func MatchKnownBrewPrefix(path string) string {
|
|
for _, prefix := range knownBrewPrefixes {
|
|
if strings.HasPrefix(path, prefix+"/Cellar/") {
|
|
return prefix
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
return MatchKnownBrewPrefix(resolved) != ""
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func updateDownloadTimeoutOrDefault(timeout time.Duration) time.Duration {
|
|
if timeout <= 0 {
|
|
return DefaultUpdateDownloadTimeout
|
|
}
|
|
return timeout
|
|
}
|
|
|
|
// 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) {
|
|
return UpdateViaDownloadWithTimeout(targetVersion, DefaultUpdateDownloadTimeout)
|
|
}
|
|
|
|
// UpdateViaDownloadWithTimeout downloads the latest release binary with a caller-selected timeout.
|
|
func UpdateViaDownloadWithTimeout(targetVersion string, downloadTimeout time.Duration) (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)
|
|
}
|
|
|
|
tag := normalizeReleaseTag(targetVersion)
|
|
release, err := fetchReleaseByTag(tag)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch release metadata: %w", err)
|
|
}
|
|
asset, err := findReleaseAsset(release.Assets, tag, runtime.GOOS, runtime.GOARCH)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
downloadURL := asset.BrowserDownloadURL
|
|
assetName := asset.Name
|
|
|
|
// Download the archive.
|
|
client := &http.Client{Timeout: updateDownloadTimeoutOrDefault(downloadTimeout)}
|
|
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 binary from the archive.
|
|
binaryName := "multica"
|
|
if runtime.GOOS == "windows" {
|
|
binaryName = "multica.exe"
|
|
}
|
|
var binaryData []byte
|
|
if runtime.GOOS == "windows" {
|
|
binaryData, err = extractBinaryFromZip(resp.Body, binaryName)
|
|
} else {
|
|
binaryData, err = extractBinaryFromTarGz(resp.Body, binaryName)
|
|
}
|
|
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. On Windows this moves the running executable
|
|
// aside first; on Unix a plain rename over the running inode is fine.
|
|
if err := replaceBinary(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
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractBinaryFromZip reads a .zip stream and returns the contents of the
|
|
// named file entry. The zip format requires random access, so the full archive
|
|
// is buffered in memory.
|
|
func extractBinaryFromZip(r io.Reader, name string) ([]byte, error) {
|
|
buf, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read zip data: %w", err)
|
|
}
|
|
|
|
zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zip reader: %w", err)
|
|
}
|
|
|
|
for _, f := range zr.File {
|
|
if filepath.Base(f.Name) == name && !f.FileInfo().IsDir() {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open zip entry: %w", err)
|
|
}
|
|
defer rc.Close()
|
|
|
|
data, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read binary: %w", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("binary %q not found in archive", name)
|
|
}
|