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:
parent
ed9aef8f39
commit
606930725a
5 changed files with 161 additions and 39 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue