diff --git a/apps/web/features/runtimes/components/update-section.tsx b/apps/web/features/runtimes/components/update-section.tsx index b85d1ba2..9d94650d 100644 --- a/apps/web/features/runtimes/components/update-section.tsx +++ b/apps/web/features/runtimes/components/update-section.tsx @@ -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" diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index bd2bda18..fc57703a 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -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 diff --git a/server/cmd/multica/cmd_update.go b/server/cmd/multica/cmd_update.go index 34142f8b..f133f4e0 100644 --- a/server/cmd/multica/cmd_update.go +++ b/server/cmd/multica/cmd_update.go @@ -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 } diff --git a/server/internal/cli/update.go b/server/internal/cli/update.go index 66660b03..afd4c719 100644 --- a/server/internal/cli/update.go +++ b/server/internal/cli/update.go @@ -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 + } + } +} + diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 29621a3b..9d1b494a 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -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