Detailed changes
@@ -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
+}
@@ -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
+}
@@ -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",
@@ -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)
@@ -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))
}