diff --git a/cli/upgrade.go b/cli/upgrade.go new file mode 100644 index 0000000000000000000000000000000000000000..8b7f5d3e8c3b6ef5cfa7ebbe86c30d39f99a44d5 --- /dev/null +++ b/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 +} diff --git a/cli/upgrade_v1.go b/cli/upgrade_v1.go new file mode 100644 index 0000000000000000000000000000000000000000..75ce07a2150a022fd6dbfc5bc9bef1e853d969a9 --- /dev/null +++ b/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 +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 38aef591e4bcea615eaf759284b189c76626e0be..90169284a76705530881ce37941aead7accdbed1 100644 --- a/i18n/locales/en.json +++ b/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", diff --git a/main.go b/main.go index e8c62cacc28157e2012adaf4e25011fe5ddbb3c6..ebe0569e21430d9943d5b40af6559af054e3a626 100644 --- a/main.go +++ b/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) diff --git a/tui/choice.go b/tui/choice.go index 8b8b047c3f96d5298a2317c14e70ac5d0b8b630b..98b61f88b1d803d865a2e14778a8264e30a78474 100644 --- a/tui/choice.go +++ b/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)) }