chore: add rc upgrade message (#1617)

Drew Smirnoff created

## What?

Adds a message at the bottom of `choice.go` if v1 release candidate is
out, with the command `matcha upgrade-v1` to update from stable v0
release to the candidate.

## Why?

We are soon going to be releasing the first release candidate for the v1
release, and there is no notification in matcha for it.

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

cli/upgrade.go       | 282 ++++++++++++++++++++++++++++++++++++++++++
cli/upgrade_v1.go    | 161 ++++++++++++++++++++++++
i18n/locales/en.json |   3 
main.go              | 306 ++++++++-------------------------------------
tui/choice.go        | 147 +++++++++++++++------
5 files changed, 602 insertions(+), 297 deletions(-)

Detailed changes

cli/upgrade.go šŸ”—

@@ -0,0 +1,282 @@
+package cli
+
+import (
+	"archive/tar"
+	"archive/zip"
+	"compress/gzip"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"time"
+
+	"github.com/floatpane/matcha/internal/httpclient"
+)
+
+// Release describes a GitHub release and its assets.
+type Release struct {
+	TagName    string `json:"tag_name"`
+	Prerelease bool   `json:"prerelease"`
+	Assets     []struct {
+		Name               string `json:"name"`
+		BrowserDownloadURL string `json:"browser_download_url"`
+	} `json:"assets"`
+}
+
+const (
+	goosDarwin  = "darwin"
+	goosLinux   = "linux"
+	goosWindows = "windows"
+)
+
+const maxBinarySize = 512 * 1024 * 1024 // 512 MiB
+
+// copyLimited copies at most maxBinarySize bytes from src to dst. It is used to
+// avoid decompression bomb attacks when extracting binaries from archives.
+func copyLimited(dst io.Writer, src io.Reader) error {
+	n, err := io.CopyN(dst, src, maxBinarySize+1)
+	if err != nil && !errors.Is(err, io.EOF) {
+		return err
+	}
+	if n > maxBinarySize {
+		return fmt.Errorf("extracted binary exceeds maximum size of %d bytes", maxBinarySize)
+	}
+	return nil
+}
+
+// FindAsset returns the name and download URL for a release asset matching the
+// given OS and architecture.
+func FindAsset(rel *Release, osName, arch string) (string, string, error) {
+	for _, a := range rel.Assets {
+		n := strings.ToLower(a.Name)
+		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
+			return a.Name, a.BrowserDownloadURL, nil
+		}
+	}
+	for _, a := range rel.Assets {
+		n := strings.ToLower(a.Name)
+		if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
+			return a.Name, a.BrowserDownloadURL, nil
+		}
+	}
+	return "", "", fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
+}
+
+// UpgradeBinaryFromAsset downloads the named release asset, extracts the matcha
+// binary, and replaces the running executable.
+func UpgradeBinaryFromAsset(assetURL, assetName, tag, cmdName string) error {
+	execPath, err := os.Executable()
+	if err != nil {
+		return fmt.Errorf("could not determine executable path: %w", err)
+	}
+	execDir := filepath.Dir(execPath)
+
+	if err := ensureWritable(execDir, cmdName); err != nil {
+		return err
+	}
+
+	fmt.Printf("Found release asset: %s\n", assetName)
+	fmt.Println("Downloading...")
+
+	client := httpclient.NewWithRedirectCap(httpclient.UpdateCheckTimeout, 5)
+	respAsset, err := client.Get(assetURL) //nolint:noctx
+	if err != nil {
+		return fmt.Errorf("download failed: %w", err)
+	}
+	defer respAsset.Body.Close() //nolint:errcheck
+
+	// Create a temp file for the download.
+	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
+	if err != nil {
+		return fmt.Errorf("could not create temp dir: %w", err)
+	}
+	defer os.RemoveAll(tmpDir) //nolint:errcheck
+
+	assetPath := filepath.Join(tmpDir, assetName)
+	outFile, err := os.Create(assetPath)
+	if err != nil {
+		return fmt.Errorf("could not create temp file: %w", err)
+	}
+	_, err = io.Copy(outFile, respAsset.Body)
+	if err != nil {
+		_ = outFile.Close()
+		return fmt.Errorf("could not write asset to disk: %w", err)
+	}
+	if err := outFile.Close(); err != nil {
+		return fmt.Errorf("could not finalize asset file: %w", err)
+	}
+
+	// Extract binary from archive.
+	binPath, err := extractBinaryFromArchive(assetPath, assetName, tmpDir)
+	if err != nil {
+		return err
+	}
+
+	// Replace the executable.
+	if err := replaceExecutable(binPath, execDir); err != nil {
+		return err
+	}
+
+	fmt.Println("Successfully updated matcha to", tag)
+	return nil
+}
+
+func ensureWritable(execDir, cmdName string) error {
+	testFile := filepath.Join(execDir, ".matcha_update_test")
+	if _, err := os.Create(testFile); err != nil {
+		if runtime.GOOS != goosWindows && os.Geteuid() != 0 {
+			fmt.Println("\nāš ļø  Permission denied: Cannot write to installation directory.")
+			fmt.Printf("   Try running with sudo: sudo %s\n", cmdName)
+			fmt.Println("   Or reinstall using your package manager.")
+			return fmt.Errorf("permission denied: cannot write to %s", execDir)
+		}
+		return fmt.Errorf("cannot write to installation directory %s: %w", execDir, err)
+	}
+	_ = os.Remove(testFile)
+	return nil
+}
+
+// extractBinaryFromArchive extracts the matcha binary from a tar.gz, tgz, or zip archive.
+func extractBinaryFromArchive(assetPath, assetName, tmpDir string) (string, error) {
+	binaryName := "matcha"
+	if runtime.GOOS == goosWindows {
+		binaryName = "matcha.exe"
+	}
+
+	var binPath string
+	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") { //nolint:gocritic
+		f, err := os.Open(assetPath)
+		if err != nil {
+			return "", fmt.Errorf("could not open archive: %w", err)
+		}
+		defer f.Close() //nolint:errcheck
+		gzr, err := gzip.NewReader(f)
+		if err != nil {
+			return "", fmt.Errorf("could not create gzip reader: %w", err)
+		}
+		tr := tar.NewReader(gzr)
+		for {
+			hdr, err := tr.Next()
+			if err == io.EOF {
+				break
+			}
+			if err != nil {
+				return "", fmt.Errorf("error reading tar: %w", err)
+			}
+			name := filepath.Base(hdr.Name)
+			if name == binaryName || (strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg)) {
+				binPath = filepath.Join(tmpDir, binaryName)
+				out, err := os.Create(binPath)
+				if err != nil {
+					return "", fmt.Errorf("could not create binary file: %w", err)
+				}
+				if err := copyLimited(out, tr); err != nil {
+					_ = out.Close()
+					return "", fmt.Errorf("could not extract binary: %w", err)
+				}
+				if err := out.Close(); err != nil {
+					return "", fmt.Errorf("could not finalize extracted binary: %w", err)
+				}
+				if err := os.Chmod(binPath, 0755); err != nil { // #nosec G302 -- binary must be executable
+					return "", fmt.Errorf("could not make binary executable: %w", err)
+				}
+				break
+			}
+		}
+	} else if strings.HasSuffix(assetName, ".zip") {
+		zr, err := zip.OpenReader(assetPath)
+		if err != nil {
+			return "", fmt.Errorf("could not open zip archive: %w", err)
+		}
+		defer zr.Close() //nolint:errcheck
+		for _, zf := range zr.File {
+			name := filepath.Base(zf.Name)
+			if name == binaryName || (strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir()) {
+				rc, err := zf.Open()
+				if err != nil {
+					return "", fmt.Errorf("could not open file in zip: %w", err)
+				}
+				binPath = filepath.Join(tmpDir, binaryName)
+				out, err := os.Create(binPath)
+				if err != nil {
+					rc.Close() //nolint:errcheck,gosec
+					return "", fmt.Errorf("could not create binary file: %w", err)
+				}
+				if err := copyLimited(out, rc); err != nil {
+					_ = out.Close()
+					_ = rc.Close()
+					return "", fmt.Errorf("could not extract binary: %w", err)
+				}
+				if err := out.Close(); err != nil {
+					_ = rc.Close()
+					return "", fmt.Errorf("could not finalize extracted binary: %w", err)
+				}
+				if err := rc.Close(); err != nil {
+					return "", fmt.Errorf("could not close zip entry: %w", err)
+				}
+				if err := os.Chmod(binPath, 0755); err != nil { // #nosec G302 -- binary must be executable
+					return "", fmt.Errorf("could not make binary executable: %w", err)
+				}
+				break
+			}
+		}
+	} else {
+		binPath = assetPath
+		if err := os.Chmod(binPath, 0755); err != nil { // #nosec G302 -- binary must be executable
+			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
+		}
+	}
+
+	if binPath == "" {
+		return "", fmt.Errorf("could not locate matcha binary inside the release artifact")
+	}
+
+	return binPath, nil
+}
+
+// replaceExecutable atomically replaces the current executable with a new binary.
+func replaceExecutable(binPath, execDir string) error {
+	execPath, err := os.Executable()
+	if err != nil {
+		return fmt.Errorf("could not determine executable path: %w", err)
+	}
+
+	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
+	in, err := os.Open(binPath)
+	if err != nil {
+		return fmt.Errorf("could not open new binary: %w", err)
+	}
+	defer in.Close()                                                          //nolint:errcheck
+	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) // #nosec G302 -- binary must be executable
+	if err != nil {
+		return fmt.Errorf("could not create temp binary in target dir: %w", err)
+	}
+
+	defer func() {
+		cerr := out.Close()
+		if err == nil && cerr != nil {
+			err = fmt.Errorf("could not flush new binary to disk: %w", cerr)
+		}
+	}()
+
+	if _, err = io.Copy(out, in); err != nil {
+		return fmt.Errorf("could not write new binary to disk: %w", err)
+	}
+
+	if runtime.GOOS == goosWindows {
+		oldPath := execPath + ".old"
+		_ = os.Remove(oldPath)
+		if err := os.Rename(execPath, oldPath); err != nil {
+			return fmt.Errorf("could not move old executable out of the way: %w", err)
+		}
+	}
+
+	if err = os.Rename(tmpNew, execPath); err != nil {
+		return fmt.Errorf("could not replace executable: %w", err)
+	}
+
+	return nil
+}

cli/upgrade_v1.go šŸ”—

@@ -0,0 +1,161 @@
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"os"
+	"os/exec"
+	"regexp"
+	"runtime"
+	"strings"
+
+	"github.com/floatpane/matcha/internal/httpclient"
+)
+
+var v1RCRegex = regexp.MustCompile(`^v?1\.0\.0-rc\d+$`)
+
+// RunUpgradeV1 handles `matcha upgrade-v1` and upgrades a pre-v1 install (or
+// release candidate) to the v1 release.
+func RunUpgradeV1(args []string) error {
+	_ = args
+
+	fmt.Println("Checking for v1 release...")
+
+	rel, tag, err := fetchV1Release()
+	if err != nil {
+		return err
+	}
+
+	fmt.Printf("Target version: v%s\n", tag)
+
+	switch runtime.GOOS {
+	case goosDarwin:
+		if tryHomebrewV1Upgrade(true) {
+			return nil
+		}
+	case goosLinux:
+		if trySnapV1Refresh() {
+			return nil
+		}
+		if tryHomebrewV1Upgrade(false) {
+			return nil
+		}
+	}
+
+	// Windows or fallbacks: download the binary directly.
+	osName := runtime.GOOS
+	arch := runtime.GOARCH
+	assetName, assetURL, err := FindAsset(rel, osName, arch)
+	if err != nil {
+		return err
+	}
+	return UpgradeBinaryFromAsset(assetURL, assetName, "v"+tag, "matcha upgrade-v1")
+}
+
+func fetchV1Release() (*Release, string, error) {
+	client := httpclient.NewWithRedirectCap(httpclient.UpdateCheckTimeout, 5)
+
+	const apiTag = "https://api.github.com/repos/floatpane/matcha/releases/tags/v1.0.0"
+	resp, err := client.Get(apiTag) //nolint:noctx
+	if err == nil {
+		if resp.StatusCode == http.StatusOK {
+			defer resp.Body.Close() //nolint:errcheck
+			var rel Release
+			if err := json.NewDecoder(resp.Body).Decode(&rel); err == nil && !rel.Prerelease {
+				tag := strings.TrimPrefix(rel.TagName, "v")
+				return &rel, tag, nil
+			}
+		} else {
+			if err := resp.Body.Close(); err != nil {
+				fmt.Printf("warning: non-fatal response body close error: %v\n", err)
+			}
+		}
+	}
+
+	const apiList = "https://api.github.com/repos/floatpane/matcha/releases"
+	resp, err = client.Get(apiList) //nolint:noctx
+	if err != nil {
+		return nil, "", fmt.Errorf("could not query releases: %w", err)
+	}
+	defer resp.Body.Close() //nolint:errcheck
+
+	var rels []Release
+	if err := json.NewDecoder(resp.Body).Decode(&rels); err != nil {
+		return nil, "", fmt.Errorf("could not parse releases: %w", err)
+	}
+
+	for i := range rels {
+		tag := strings.TrimPrefix(rels[i].TagName, "v")
+		if strings.HasPrefix(tag, "1.0.") && !strings.Contains(tag, "-") && !rels[i].Prerelease {
+			return &rels[i], tag, nil
+		}
+	}
+	for i := range rels {
+		tag := strings.TrimPrefix(rels[i].TagName, "v")
+		if v1RCRegex.MatchString(tag) {
+			return &rels[i], tag, nil
+		}
+	}
+	return nil, "", fmt.Errorf("no v1 release found")
+}
+
+func tryHomebrewV1Upgrade(cask bool) bool {
+	if _, err := exec.LookPath("brew"); err != nil {
+		return false
+	}
+
+	formula := "floatpane/matcha/matcha@v1"
+	var installArgs, upgradeArgs []string
+	if cask {
+		installArgs = []string{"install", "--cask", formula}
+		upgradeArgs = []string{"upgrade", "--cask", formula}
+		fmt.Println("Attempting to upgrade via Homebrew cask to v1.")
+	} else {
+		installArgs = []string{"install", formula}
+		upgradeArgs = []string{"upgrade", formula}
+		fmt.Println("Attempting to upgrade via Homebrew to v1.")
+	}
+
+	cmd := exec.Command("brew", installArgs...) //nolint:noctx
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err == nil {
+		fmt.Println("Successfully upgraded via Homebrew.")
+		return true
+	}
+
+	cmd = exec.Command("brew", upgradeArgs...) //nolint:noctx
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err == nil {
+		fmt.Println("Successfully upgraded via Homebrew.")
+		return true
+	}
+
+	fmt.Println("Homebrew v1 upgrade failed.")
+	return false
+}
+
+func trySnapV1Refresh() bool {
+	if _, err := exec.LookPath("snap"); err != nil {
+		return false
+	}
+
+	cmdCheck := exec.Command("snap", "list", "matcha") //nolint:noctx
+	if err := cmdCheck.Run(); err != nil {
+		return false
+	}
+
+	fmt.Println("Detected Snap package — attempting to refresh to candidate v1.")
+	cmd := exec.Command("snap", "refresh", "matcha", "--candidate") //nolint:noctx
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err == nil {
+		fmt.Println("Successfully refreshed snap to candidate v1.")
+		return true
+	}
+
+	fmt.Println("Snap candidate refresh failed.")
+	return false
+}

i18n/locales/en.json šŸ”—

@@ -79,7 +79,8 @@
       "drafts": "Drafts",
       "help": "Use ↑/↓ to navigate, enter to select, and ctrl+c to quit.",
       "unknown": "unknown",
-      "update_available": "Update available: {latest} (installed: {current}) — run `matcha update` to upgrade"
+      "update_available": "Update available: {latest} (installed: {current}) — run `matcha update` to upgrade",
+      "upgrade_v1_note": "v1.0.0 is in release candidate — run `matcha upgrade-v1` to upgrade to v1."
     },
     "folder_inbox": {
       "folders_title": "Folders",

main.go šŸ”—

@@ -1,9 +1,6 @@
 package main
 
 import (
-	"archive/tar"
-	"archive/zip"
-	"compress/gzip"
 	"context"
 	"encoding/base64"
 	"encoding/json"
@@ -91,6 +88,13 @@ type UpdateAvailableMsg struct {
 	Current string
 }
 
+// V1RCAvailableMsg is sent into the TUI when a v1.0.0-rcN release is available
+// while the installed version is still pre-v1.
+type V1RCAvailableMsg struct {
+	Latest  string
+	Current string
+}
+
 // internal struct for parsing GitHub release JSON.
 type pendingEmailAction struct {
 	jobID      string
@@ -106,13 +110,7 @@ type pendingEmailAction struct {
 	folderSnap []fetcher.Email
 }
 
-type githubRelease struct {
-	TagName string `json:"tag_name"`
-	Assets  []struct {
-		Name               string `json:"name"`
-		BrowserDownloadURL string `json:"browser_download_url"`
-	} `json:"assets"`
-}
+type githubRelease = matchaCli.Release
 
 type mainModel struct {
 	current       tea.Model
@@ -252,7 +250,7 @@ func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
 }
 
 func (m *mainModel) Init() tea.Cmd {
-	cmds := []tea.Cmd{m.current.Init(), checkForUpdatesCmd()}
+	cmds := []tea.Cmd{m.current.Init(), checkForUpdatesCmd(), checkForV1RCCmd()}
 	if m.showLogPanel && m.logCh != nil {
 		cmds = append(cmds, waitForLogEntry(m.logCh))
 	}
@@ -3673,6 +3671,11 @@ func detectInstalledVersion() string {
 	return v
 }
 
+var (
+	v0ReleaseRegex   = regexp.MustCompile(`^0\.\d+\.\d+$`)
+	v1RCReleaseRegex = regexp.MustCompile(`^1\.0\.0-rc\d+$`)
+)
+
 /*
 checkForUpdatesCmd queries GitHub for the latest release tag and returns a
 tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
@@ -3702,6 +3705,37 @@ func checkForUpdatesCmd() tea.Cmd {
 	}
 }
 
+// checkForV1RCCmd queries GitHub for the newest v1.0.0-rcN release and returns
+// a V1RCAvailableMsg when the installed version is still pre-v1.
+func checkForV1RCCmd() tea.Cmd {
+	return func() tea.Msg {
+		const api = "https://api.github.com/repos/floatpane/matcha/releases"
+		resp, err := httpClient.Get(api)
+		if err != nil {
+			return nil
+		}
+		defer resp.Body.Close() //nolint:errcheck
+
+		var rels []githubRelease
+		if err := json.NewDecoder(resp.Body).Decode(&rels); err != nil {
+			return nil
+		}
+
+		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
+		if installed == "" || !v0ReleaseRegex.MatchString(installed) {
+			return nil
+		}
+
+		for _, rel := range rels {
+			latest := strings.TrimPrefix(rel.TagName, "v")
+			if v1RCReleaseRegex.MatchString(latest) {
+				return V1RCAvailableMsg{Latest: latest, Current: installed}
+			}
+		}
+		return nil
+	}
+}
+
 // runUpdateCLI implements the CLI entrypoint for `matcha update`.
 // It detects the likely installation method and attempts the appropriate
 // update path (Homebrew, Snap, or GitHub release binary extract).
@@ -4163,253 +4197,16 @@ func tryScoopUpdate() bool {
 	return false
 }
 
-// extractBinaryFromArchive extracts the matcha binary from a tar.gz, tgz, or zip archive
-func extractBinaryFromArchive(assetPath, assetName, tmpDir string) (string, error) {
-	// Determine the expected binary name based on the OS.
-	binaryName := "matcha"
-	if runtime.GOOS == goosWindows {
-		binaryName = "matcha.exe"
-	}
-
-	// Extract the binary from the archive.
-	var binPath string
-	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") { //nolint:gocritic
-		f, err := os.Open(assetPath)
-		if err != nil {
-			return "", fmt.Errorf("could not open archive: %w", err)
-		}
-		defer f.Close() //nolint:errcheck
-		gzr, err := gzip.NewReader(f)
-		if err != nil {
-			return "", fmt.Errorf("could not create gzip reader: %w", err)
-		}
-		tr := tar.NewReader(gzr)
-		for {
-			hdr, err := tr.Next()
-			if err == io.EOF {
-				break
-			}
-			if err != nil {
-				return "", fmt.Errorf("error reading tar: %w", err)
-			}
-			name := filepath.Base(hdr.Name)
-			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
-				binPath = filepath.Join(tmpDir, binaryName)
-				out, err := os.Create(binPath)
-				if err != nil {
-					return "", fmt.Errorf("could not create binary file: %w", err)
-				}
-				if _, err := io.Copy(out, tr); err != nil { //nolint:gosec
-					_ = out.Close()
-					return "", fmt.Errorf("could not extract binary: %w", err)
-				}
-				if err := out.Close(); err != nil {
-					return "", fmt.Errorf("could not finalize extracted binary: %w", err)
-				}
-				if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
-					return "", fmt.Errorf("could not make binary executable: %w", err)
-				}
-				break
-			}
-		}
-	} else if strings.HasSuffix(assetName, ".zip") {
-		zr, err := zip.OpenReader(assetPath)
-		if err != nil {
-			return "", fmt.Errorf("could not open zip archive: %w", err)
-		}
-		defer zr.Close() //nolint:errcheck
-		for _, zf := range zr.File {
-			name := filepath.Base(zf.Name)
-			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
-				rc, err := zf.Open()
-				if err != nil {
-					return "", fmt.Errorf("could not open file in zip: %w", err)
-				}
-				binPath = filepath.Join(tmpDir, binaryName)
-				out, err := os.Create(binPath)
-				if err != nil {
-					rc.Close() //nolint:errcheck,gosec
-					return "", fmt.Errorf("could not create binary file: %w", err)
-				}
-				if _, err := io.Copy(out, rc); err != nil { //nolint:gosec
-					_ = out.Close()
-					_ = rc.Close()
-					return "", fmt.Errorf("could not extract binary: %w", err)
-				}
-				if err := out.Close(); err != nil {
-					_ = rc.Close()
-					return "", fmt.Errorf("could not finalize extracted binary: %w", err)
-				}
-				if err := rc.Close(); err != nil {
-					return "", fmt.Errorf("could not close zip entry: %w", err)
-				}
-				if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
-					return "", fmt.Errorf("could not make binary executable: %w", err)
-				}
-				break
-			}
-		}
-	} else {
-		// For non-archive assets, assume the asset is the binary itself.
-		binPath = assetPath
-		if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec
-			// ignore chmod errors but warn
-			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
-		}
-	}
-
-	if binPath == "" {
-		return "", fmt.Errorf("could not locate matcha binary inside the release artifact")
-	}
-
-	return binPath, nil
-}
-
-// replaceExecutable atomically replaces the current executable with a new binary
-func replaceExecutable(binPath, execDir string) error {
-	execPath, err := os.Executable()
-	if err != nil {
-		return fmt.Errorf("could not determine executable path: %w", err)
-	}
-
-	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
-	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
-	in, err := os.Open(binPath)
-	if err != nil {
-		return fmt.Errorf("could not open new binary: %w", err)
-	}
-	defer in.Close()                                                          //nolint:errcheck
-	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) //nolint:gosec
-	if err != nil {
-		return fmt.Errorf("could not create temp binary in target dir: %w", err)
-	}
-
-	defer func() {
-		cerr := out.Close()
-		if err == nil && cerr != nil {
-			err = fmt.Errorf("could not flush new binary to disk: %w", cerr)
-		}
-	}()
-
-	if _, err = io.Copy(out, in); err != nil {
-		return fmt.Errorf("could not write new binary to disk: %w", err)
-	}
-
-	// On Windows, a running executable cannot be overwritten directly.
-	// Move the old binary out of the way first, then rename the new one in.
-	if runtime.GOOS == goosWindows {
-		oldPath := execPath + ".old"
-		_ = os.Remove(oldPath) // clean up any previous leftover
-		if err := os.Rename(execPath, oldPath); err != nil {
-			return fmt.Errorf("could not move old executable out of the way: %w", err)
-		}
-	}
-
-	if err = os.Rename(tmpNew, execPath); err != nil {
-		return fmt.Errorf("could not replace executable: %w", err)
-	}
-
-	return nil
-}
-
 // runUpdateCLIManual handles manual binary download and replacement
 func runUpdateCLIManual(latestTag string, rel githubRelease) error {
-	// Otherwise attempt to download the proper release asset and replace the binary.
 	osName := runtime.GOOS
 	arch := runtime.GOARCH
 
-	// Check if we have write permissions to the executable directory
-	execPath, err := os.Executable()
-	if err != nil {
-		return fmt.Errorf("could not determine executable path: %w", err)
-	}
-	execDir := filepath.Dir(execPath)
-
-	// Test if we can write to the directory
-	testFile := filepath.Join(execDir, ".matcha_update_test")
-	if _, err := os.Create(testFile); err != nil {
-		// Cannot write - check if running with sudo or suggest it
-		if os.Geteuid() != 0 {
-			fmt.Println("\nāš ļø  Permission denied: Cannot write to installation directory.")
-			fmt.Println("   Try running with sudo: sudo matcha update")
-			fmt.Println("   Or reinstall using your package manager.")
-			return fmt.Errorf("permission denied: cannot write to %s", execDir)
-		}
-		// Running as root but still can't write - actual permission issue
-		return fmt.Errorf("cannot write to installation directory %s: %w", execDir, err)
-	}
-	_ = os.Remove(testFile) // Clean up test file
-
-	// Try to find a matching asset
-	var assetURL, assetName string
-	for _, a := range rel.Assets {
-		n := strings.ToLower(a.Name)
-		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
-			assetURL = a.BrowserDownloadURL
-			assetName = a.Name
-			break
-		}
-	}
-	if assetURL == "" {
-		// Try any asset that contains 'matcha' and os/arch as a fallback
-		for _, a := range rel.Assets {
-			n := strings.ToLower(a.Name)
-			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
-				assetURL = a.BrowserDownloadURL
-				assetName = a.Name
-				break
-			}
-		}
-	}
-
-	if assetURL == "" {
-		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
-	}
-
-	fmt.Printf("Found release asset: %s\n", assetName)
-	fmt.Println("Downloading...")
-
-	// Download asset
-	respAsset, err := httpClient.Get(assetURL)
-	if err != nil {
-		return fmt.Errorf("download failed: %w", err)
-	}
-	defer respAsset.Body.Close() //nolint:errcheck
-
-	// Create a temp file for the download
-	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
+	assetName, assetURL, err := matchaCli.FindAsset(&rel, osName, arch)
 	if err != nil {
-		return fmt.Errorf("could not create temp dir: %w", err)
-	}
-	defer os.RemoveAll(tmpDir) //nolint:errcheck
-
-	assetPath := filepath.Join(tmpDir, assetName)
-	outFile, err := os.Create(assetPath)
-	if err != nil {
-		return fmt.Errorf("could not create temp file: %w", err)
-	}
-	_, err = io.Copy(outFile, respAsset.Body)
-	if err != nil {
-		_ = outFile.Close()
-		return fmt.Errorf("could not write asset to disk: %w", err)
-	}
-	if err := outFile.Close(); err != nil {
-		return fmt.Errorf("could not finalize asset file: %w", err)
-	}
-
-	// Extract binary from archive
-	binPath, err := extractBinaryFromArchive(assetPath, assetName, tmpDir)
-	if err != nil {
-		return err
-	}
-
-	// Replace the executable
-	if err := replaceExecutable(binPath, execDir); err != nil {
 		return err
 	}
-
-	fmt.Println("Successfully updated matcha to", latestTag)
-	return nil
+	return matchaCli.UpgradeBinaryFromAsset(assetURL, assetName, latestTag, "matcha update")
 }
 
 func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
@@ -4565,6 +4362,15 @@ func main() { //nolint:gocyclo
 		exit(0)
 	}
 
+	// Upgrade-v1 CLI subcommand: matcha upgrade-v1
+	if len(os.Args) > 1 && os.Args[1] == "upgrade-v1" {
+		if err := matchaCli.RunUpgradeV1(os.Args[2:]); err != nil {
+			fmt.Fprintf(os.Stderr, "upgrade-v1 failed: %v\n", err)
+			exit(1)
+		}
+		exit(0)
+	}
+
 	// Marketplace TUI subcommand: matcha marketplace
 	if len(os.Args) > 1 && os.Args[1] == "marketplace" {
 		mp := tui.NewMarketplace(true)

tui/choice.go šŸ”—

@@ -3,6 +3,7 @@ package tui
 import (
 	"fmt"
 	"reflect"
+	"regexp"
 	"strings"
 
 	tea "charm.land/bubbletea/v2"
@@ -37,6 +38,8 @@ type Choice struct {
 	UpdateAvailable bool
 	LatestVersion   string
 	CurrentVersion  string
+	V1RCAvailable   bool
+	V1RCVersion     string
 	width           int
 	height          int
 	keybindWarnings []string
@@ -59,6 +62,8 @@ func NewChoice() Choice {
 		UpdateAvailable: false,
 		LatestVersion:   "",
 		CurrentVersion:  "",
+		V1RCAvailable:   false,
+		V1RCVersion:     "",
 		keybindWarnings: config.ValidateKeybinds(config.Keybinds),
 	}
 }
@@ -74,58 +79,102 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.height = msg.Height
 		return m, nil
 	case tea.KeyPressMsg:
-		kb := config.Keybinds
-		switch msg.String() {
-		case "up", kb.Global.NavUp:
-			m.cursor = (m.cursor - 1 + len(m.choices)) % len(m.choices)
-		case keyDown, kb.Global.NavDown:
-			m.cursor = (m.cursor + 1) % len(m.choices)
-		case keyEnter:
-			// Use cursor index instead of string comparison
-			idx := m.cursor
-			if idx == 0 { //nolint:gocritic
-				// Inbox
-				return m, func() tea.Msg { return GoToInboxMsg{} }
-			} else if idx == 1 {
-				// Compose
-				return m, func() tea.Msg { return GoToSendMsg{} }
-			} else if m.hasSavedDrafts && idx == 2 {
-				// Drafts
-				return m, func() tea.Msg { return GoToDraftsMsg{} }
-			} else if (m.hasSavedDrafts && idx == 3) || (!m.hasSavedDrafts && idx == 2) {
-				// Marketplace
-				return m, func() tea.Msg { return GoToMarketplaceMsg{} }
-			} else if (m.hasSavedDrafts && idx == 4) || (!m.hasSavedDrafts && idx == 3) {
-				// Settings
-				return m, func() tea.Msg { return GoToSettingsMsg{} }
-			}
-		}
+		return m, m.handleKeyPress(msg)
 	}
 
-	// Handle update notification from other package without importing its type directly.
-	// We look for a struct named 'UpdateAvailableMsg' that contains 'Latest' and 'Current' string fields.
-	rv := reflect.ValueOf(msg)
-	if rv.IsValid() && rv.Kind() == reflect.Struct && rv.Type().Name() == "UpdateAvailableMsg" {
-		f := rv.FieldByName("Latest")
-		c := rv.FieldByName("Current")
-		updated := false
-		if f.IsValid() && f.Kind() == reflect.String {
-			m.LatestVersion = f.String()
-			updated = true
-		}
-		if c.IsValid() && c.Kind() == reflect.String {
-			m.CurrentVersion = c.String()
-			updated = true
-		}
-		if updated {
-			m.UpdateAvailable = true
-			return m, nil
-		}
+	if m.handleUpdateAvailableMsg(msg) {
+		return m, nil
+	}
+	if m.handleV1RCAvailableMsg(msg) {
+		return m, nil
 	}
 
 	return m, nil
 }
 
+func (m *Choice) handleKeyPress(msg tea.KeyPressMsg) tea.Cmd {
+	kb := config.Keybinds
+	switch msg.String() {
+	case "up", kb.Global.NavUp:
+		m.cursor = (m.cursor - 1 + len(m.choices)) % len(m.choices)
+	case keyDown, kb.Global.NavDown:
+		m.cursor = (m.cursor + 1) % len(m.choices)
+	case keyEnter:
+		return m.navCmd()
+	}
+	return nil
+}
+
+func (m *Choice) navCmd() tea.Cmd {
+	idx := m.cursor
+	if !m.hasSavedDrafts && idx >= 2 {
+		idx++
+	}
+	switch idx {
+	case 0:
+		return func() tea.Msg { return GoToInboxMsg{} }
+	case 1:
+		return func() tea.Msg { return GoToSendMsg{} }
+	case 2:
+		return func() tea.Msg { return GoToDraftsMsg{} }
+	case 3:
+		return func() tea.Msg { return GoToMarketplaceMsg{} }
+	case 4:
+		return func() tea.Msg { return GoToSettingsMsg{} }
+	}
+	return nil
+}
+
+func (m *Choice) handleUpdateAvailableMsg(msg tea.Msg) bool {
+	rv := reflect.ValueOf(msg)
+	if !rv.IsValid() || rv.Kind() != reflect.Struct || rv.Type().Name() != "UpdateAvailableMsg" {
+		return false
+	}
+	updated := false
+	if f := rv.FieldByName("Latest"); f.IsValid() && f.Kind() == reflect.String {
+		m.LatestVersion = f.String()
+		updated = true
+	}
+	if c := rv.FieldByName("Current"); c.IsValid() && c.Kind() == reflect.String {
+		m.CurrentVersion = c.String()
+		updated = true
+	}
+	if updated {
+		m.UpdateAvailable = true
+	}
+	return updated
+}
+
+func (m *Choice) handleV1RCAvailableMsg(msg tea.Msg) bool {
+	rv := reflect.ValueOf(msg)
+	if !rv.IsValid() || rv.Kind() != reflect.Struct || rv.Type().Name() != "V1RCAvailableMsg" {
+		return false
+	}
+	f := rv.FieldByName("Latest")
+	if !f.IsValid() || f.Kind() != reflect.String || !v1RCRegex.MatchString(f.String()) {
+		return false
+	}
+	m.V1RCVersion = f.String()
+	m.V1RCAvailable = true
+	if c := rv.FieldByName("Current"); c.IsValid() && c.Kind() == reflect.String {
+		m.CurrentVersion = c.String()
+	}
+	return true
+}
+
+var (
+	v0Regex   = regexp.MustCompile(`^v?0\.\d+\.\d+$`)
+	v1RCRegex = regexp.MustCompile(`^v?1\.0\.0-rc\d+$`)
+)
+
+func (m Choice) isV0() bool {
+	return v0Regex.MatchString(m.CurrentVersion)
+}
+
+func (m Choice) isV1RCAvailable() bool {
+	return m.V1RCAvailable && m.isV0() && v1RCRegex.MatchString(m.V1RCVersion)
+}
+
 func (m Choice) View() tea.View {
 	var b strings.Builder
 
@@ -181,5 +230,11 @@ func (m Choice) View() tea.View {
 		mainContent += "\n\n"
 	}
 
-	return tea.NewView(docStyle.Render(mainContent + helpView))
+	content := mainContent + helpView
+	if m.isV1RCAvailable() {
+		noteStyle := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Warning).Padding(0, 1)
+		content += "\n" + noteStyle.Render(t("choice.upgrade_v1_note"))
+	}
+
+	return tea.NewView(docStyle.Render(content))
 }