mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-14 13:49:18 +02:00
* feat(desktop): support macOS cross-platform packaging * fix(desktop): use releaseType instead of publishingType in electron-builder publish config publishingType is not a valid electron-builder key; the correct GitHub provider option is releaseType. The previous value was silently ignored, causing uploads to be skipped and breaking auto-update. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): standardize artifact naming across desktop and CLI Unified scheme: `multica-<kind>-<version>-<platform>-<arch>.<ext>` so a filename alone reveals kind, version, platform, and CPU arch. Desktop (apps/desktop/electron-builder.yml): mac → multica-desktop-<v>-mac-<arch>.{dmg,zip} linux → multica-desktop-<v>-linux-<arch>.{deb,AppImage} (fixes `\${name}` expanding the scoped `@multica/desktop` into a broken `@multica/desktop-*` filename path) windows → multica-desktop-<v>-windows-<arch>.exe CLI (.goreleaser.yml): multica_<os>_<arch>.tar.gz → multica-cli-<v>-<os>-<arch>.tar.gz (adds `-cli` marker + version; switches `_` to `-` for consistency) Matrix update in apps/desktop/scripts/package.mjs `--all-platforms`: - drop mac x64 (Intel not a target yet) - add linux arm64 Final: mac arm64, win x64/arm64, linux x64/arm64. Downstream updates so install paths match the new CLI names: - scripts/install.sh - scripts/install.ps1 (URL + checksum regex) - CLI_INSTALL.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): use multica_{os}_{arch} CLI archive naming Standardize on the GoReleaser default 'multica_{os}_{arch}.{tar.gz|zip}' asset names. Install scripts and the desktop CLI bootstrap now resolve assets via checksums.txt so they work without hardcoding versions. The Go self-update path queries the GitHub release API and accepts either the new or legacy 'multica-cli-<version>-...' names so existing releases keep updating cleanly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): ship both legacy and versioned CLI archive names GoReleaser now produces both 'multica_{os}_{arch}.{ext}' (legacy) and 'multica-cli-{version}-{os}-{arch}.{ext}' (versioned) archives in every release. The legacy name keeps already-released CLIs self-updating; the versioned name is what new clients should use going forward. Self-update / install paths flipped to prefer the versioned name and fall back to legacy: - server/internal/cli/update.go (multica update) - apps/desktop/src/main/cli-release-asset.ts (desktop CLI bootstrap) - scripts/install.sh, scripts/install.ps1 (fresh install) Homebrew formula is pinned to the versioned archive via 'ids: [versioned]'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(desktop): also build Linux .rpm packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): build Linux/Windows Desktop installers in CI; detect Windows ARM64 in install.ps1 Address review feedback on PR #1262: - .github/workflows/release.yml: add a 'desktop' job that runs after the CLI 'release' job and packages the Desktop installers for Linux (AppImage/deb/rpm) and Windows (NSIS) on x64 and arm64, then publishes them to the same GitHub Release via electron-builder. macOS Desktop continues to ship through the manual release-desktop skill so it can be signed and notarized with Apple Developer credentials. - scripts/install.ps1: detect Windows ARM64 hosts via RuntimeInformation::OSArchitecture so the new windows-arm64 CLI archive is downloaded on ARM64 machines instead of always falling back to amd64. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(release): split Windows arm64 auto-update channel to avoid latest.yml collision electron-builder's update metadata file is hardcoded to `latest.yml` for Windows regardless of arch (only Linux gets an arch-suffixed name; see app-builder-lib's getArchPrefixForUpdateFile). With two separate electron-builder invocations for Windows x64 and arm64, both publish `latest.yml` to the same GitHub Release and the second upload silently overwrites the first — leaving one of the two architectures with auto- update metadata pointing at the other arch's installer. Route Windows arm64 to its own `latest-arm64` channel: * scripts/package.mjs appends `-c.publish.channel=latest-arm64` only for the Windows arm64 invocation, so x64 keeps producing `latest.yml` and arm64 produces `latest-arm64.yml` alongside it. * updater.ts pins `autoUpdater.channel = 'latest-arm64'` on Windows arm64 clients so they fetch the matching metadata file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
315 lines
8.6 KiB
Go
315 lines
8.6 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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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: 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 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)
|
|
}
|