feat(daemon): support direct download update for non-Homebrew installs (#334)

* 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>
This commit is contained in:
LinYushen 2026-04-02 15:38:06 +08:00 committed by GitHub
parent ed9aef8f39
commit 606930725a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 161 additions and 39 deletions

View file

@ -126,6 +126,9 @@ export function UpdateSection({
setOutput(result.output ?? "");
setUpdating(false);
cleanup();
// Auto-clear status after a few seconds so the UI
// refreshes to show the new version from the re-fetched runtime data.
setTimeout(() => setStatus(null), 5000);
} else if (
result.status === "failed" ||
result.status === "timeout"

View file

@ -184,9 +184,9 @@ func runDaemonBackground(cmd *cobra.Command) error {
}
if profile != "" {
fmt.Fprintf(os.Stderr, "Daemon [%s] started (pid %d)\n", profile, child.Process.Pid)
fmt.Fprintf(os.Stderr, "Daemon [%s] started (pid %d, version %s)\n", profile, child.Process.Pid, version)
} else {
fmt.Fprintf(os.Stderr, "Daemon started (pid %d)\n", child.Process.Pid)
fmt.Fprintf(os.Stderr, "Daemon started (pid %d, version %s)\n", child.Process.Pid, version)
}
fmt.Fprintf(os.Stderr, "Logs: %s\n", logPath)
return nil

View file

@ -45,13 +45,16 @@ func runUpdate(_ *cobra.Command, _ []string) error {
return nil
}
// Not installed via brew — show manual instructions.
fmt.Fprintln(os.Stderr, "multica was not installed via Homebrew.")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "To install via Homebrew (recommended):")
fmt.Fprintln(os.Stderr, " brew install multica-ai/tap/multica")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Or download the latest release from:")
fmt.Fprintln(os.Stderr, " https://github.com/multica-ai/multica/releases/latest")
// Not installed via brew — download binary directly from GitHub Releases.
if latest == nil {
return fmt.Errorf("could not determine latest version; check https://github.com/multica-ai/multica/releases/latest")
}
targetVersion := latest.TagName
fmt.Fprintf(os.Stderr, "Downloading %s from GitHub Releases...\n", targetVersion)
output, err := cli.UpdateViaDownload(targetVersion)
if err != nil {
return fmt.Errorf("update failed: %w", err)
}
fmt.Fprintf(os.Stderr, "%s\nUpdate complete.\n", output)
return nil
}

View file

@ -1,12 +1,16 @@
package cli
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
@ -87,9 +91,106 @@ func UpdateViaBrew() (string, error) {
return string(out), nil
}
// DetectNewBinaryPath returns the path to the multica binary after an update.
// It uses exec.LookPath to find the binary in PATH, which will resolve to the
// updated version after a brew upgrade.
func DetectNewBinaryPath() (string, error) {
return exec.LookPath("multica")
// 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
}
}
}

View file

@ -70,7 +70,7 @@ func (d *Daemon) Run(ctx context.Context) error {
for name := range d.cfg.Agents {
agentNames = append(agentNames, name)
}
logFields := []any{"agents", agentNames, "server", d.cfg.ServerBaseURL}
logFields := []any{"version", d.cfg.CLIVersion, "agents", agentNames, "server", d.cfg.ServerBaseURL}
if d.cfg.Profile != "" {
logFields = append(logFields, "profile", d.cfg.Profile)
}
@ -555,26 +555,32 @@ func (d *Daemon) handleUpdate(ctx context.Context, runtimeID string, update *Pen
"status": "running",
})
// Check if installed via Homebrew.
if !cli.IsBrewInstall() {
d.logger.Warn("CLI not installed via Homebrew, cannot auto-update")
d.client.ReportUpdateResult(ctx, runtimeID, update.ID, map[string]any{
"status": "failed",
"error": "CLI was not installed via Homebrew. Please update manually: https://github.com/multica-ai/multica/releases/latest",
})
return
}
// Execute brew upgrade.
d.logger.Info("updating CLI via Homebrew...")
output, err := cli.UpdateViaBrew()
if err != nil {
d.logger.Error("CLI update failed", "error", err, "output", output)
d.client.ReportUpdateResult(ctx, runtimeID, update.ID, map[string]any{
"status": "failed",
"error": fmt.Sprintf("brew upgrade failed: %v", err),
})
return
// Try Homebrew first, fall back to direct download.
var output string
if cli.IsBrewInstall() {
d.logger.Info("updating CLI via Homebrew...")
var err error
output, err = cli.UpdateViaBrew()
if err != nil {
d.logger.Error("CLI update failed", "error", err, "output", output)
d.client.ReportUpdateResult(ctx, runtimeID, update.ID, map[string]any{
"status": "failed",
"error": fmt.Sprintf("brew upgrade failed: %v", err),
})
return
}
} else {
d.logger.Info("updating CLI via direct download...", "target_version", update.TargetVersion)
var err error
output, err = cli.UpdateViaDownload(update.TargetVersion)
if err != nil {
d.logger.Error("CLI update failed", "error", err)
d.client.ReportUpdateResult(ctx, runtimeID, update.ID, map[string]any{
"status": "failed",
"error": fmt.Sprintf("download update failed: %v", err),
})
return
}
}
d.logger.Info("CLI update completed successfully", "output", output)
@ -588,14 +594,23 @@ func (d *Daemon) handleUpdate(ctx context.Context, runtimeID string, update *Pen
}
// triggerRestart initiates a graceful daemon restart after a successful CLI update.
// It finds the new binary path and cancels the daemon context so Run() returns.
// For brew installs, it keeps the symlink path (e.g. /opt/homebrew/bin/multica)
// so the restarted daemon picks up the new Cellar version automatically.
// For non-brew installs, it resolves to the absolute path of the replaced binary.
// The caller (cmd_daemon.go) checks RestartBinary() and launches the new process.
func (d *Daemon) triggerRestart() {
newBin, err := cli.DetectNewBinaryPath()
newBin, err := os.Executable()
if err != nil {
d.logger.Error("could not find updated binary for restart", "error", err)
d.logger.Error("could not resolve executable path for restart", "error", err)
return
}
// Only resolve symlinks for non-brew installs. Brew uses a symlink that
// points to the latest Cellar version, so we must preserve it.
if !cli.IsBrewInstall() {
if resolved, err := filepath.EvalSymlinks(newBin); err == nil {
newBin = resolved
}
}
d.logger.Info("scheduling daemon restart", "new_binary", newBin)
d.restartBinary = newBin