diff --git a/README.md b/README.md index f23b73ec..ac28f06d 100644 --- a/README.md +++ b/README.md @@ -112,47 +112,77 @@ $ av stack sync ``` -# Installation +# Installation & Upgrade -`av` is available for macOS and Linux. In order to interact with GitHub, `av` -uses the GitHub API token. If you have [GitHub CLI](https://cli.github.com/) -installed, `av` will use the token automatically from the GitHub CLI. It is -recommended to install both. +`av` is available for macOS and Linux. You can install and upgrade it using the following methods: ## macOS +Install via Homebrew: ```sh brew install gh aviator-co/tap/av ``` +Upgrade: +```sh +brew upgrade av +``` + ## Arch Linux (AUR) -Published as [`av-cli-bin`](https://aur.archlinux.org/packages/av-cli-bin) in -AUR. +Install via AUR (published as [`av-cli-bin`](https://aur.archlinux.org/packages/av-cli-bin)): +```sh +yay -S av-cli-bin +``` +Upgrade: ```sh -yay av-cli +yay -S av-cli-bin ``` ## Debian/Ubuntu -Download the `.deb` file from the [releases page](https://github.com/aviator-co/av/releases). - +Download the `.deb` file from the [releases page](https://github.com/aviator-co/av/releases): ```sh -apt install ./av_$VERSION_linux_$ARCH.deb +# Install +sudo dpkg -i ./av_$VERSION_linux_$ARCH.deb + +# Upgrade +av upgrade # or use dpkg -i with the new version ``` ## RPM-based systems -Download the `.rpm` file from the [releases page](https://github.com/aviator-co/av/releases). +Download the `.rpm` file from the [releases page](https://github.com/aviator-co/av/releases): +```sh +# Install +sudo rpm -i ./av_$VERSION_linux_$ARCH.rpm +# Upgrade +av upgrade # or use rpm -U with the new version +``` + +## Binary installation + +1. Download the binary for your system from the [releases page](https://github.com/aviator-co/av/releases) +2. Extract and install the binary: ```sh -rpm -i ./av_$VERSION_linux_$ARCH.rpm +# Download and install +curl -L -o av.tar.gz "https://github.com/aviator-co/av/releases/latest/download/av_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m).tar.gz" +sudo tar xzf av.tar.gz -C /usr/local/bin + +# Upgrade +av upgrade # or repeat the installation steps with the new version ``` -## Binary download +## Automatic upgrades + +Once installed, you can upgrade `av` using the built-in upgrade command: +```sh +av upgrade +``` -Download the binary from the [releases page](https://github.com/aviator-co/av/releases). +This command will automatically detect how `av` was installed and perform the appropriate upgrade. # Setup diff --git a/cmd/av/main.go b/cmd/av/main.go index 8019c2ec..bcbcd635 100644 --- a/cmd/av/main.go +++ b/cmd/av/main.go @@ -99,6 +99,7 @@ func init() { stackCmd, versionCmd, authCmd, + upgradeCmd, ) } @@ -109,7 +110,12 @@ func main() { colors.SetupBackgroundColorTypeFromEnv() err := rootCmd.Execute() logrus.WithField("duration", time.Since(startTime)).Debug("command exited") - checkCliVersion() + + // Skip version check if running the upgrade command + if len(os.Args) > 1 && os.Args[1] != "upgrade" { + checkCliVersion() + } + var exitSilently actions.ErrExitSilently if errors.As(err, &exitSilently) { os.Exit(exitSilently.ExitCode) @@ -157,7 +163,7 @@ func checkCliVersion() { c.Sprint(" => "), color.GreenString(latest), "\n", - c.Sprint(">> https://docs.aviator.co/reference/aviator-cli/installation#upgrade\n"), + c.Sprint(">> Run `av upgrade` or see https://docs.aviator.co/reference/aviator-cli/installation#upgrade for other methods\n"), ) } } diff --git a/cmd/av/upgrade.go b/cmd/av/upgrade.go new file mode 100644 index 00000000..03375bc9 --- /dev/null +++ b/cmd/av/upgrade.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "os" + "runtime" + + "github.com/aviator-co/av/internal/actions" + "github.com/aviator-co/av/internal/utils/colors" + "github.com/spf13/cobra" +) + +var upgradeCmd = &cobra.Command{ + Use: "upgrade", + Short: "Upgrade the av CLI to the latest version", + Long: `Upgrade the av CLI to the latest version. + +This command checks for the latest release and updates the CLI accordingly. +If the CLI was installed via a package manager (e.g., Homebrew, AUR), it will +suggest using the package manager to perform the upgrade.`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := actions.UpgradeCLI(runtime.GOOS, runtime.GOARCH); err != nil { + fmt.Fprintln(os.Stderr, colors.Failure("Failed to upgrade av CLI:", err)) + return actions.ErrExitSilently{ExitCode: 1} + } + fmt.Fprintln(os.Stdout, colors.Success("Successfully upgraded av CLI to the latest version.")) + return nil + }, +} diff --git a/internal/actions/upgrade.go b/internal/actions/upgrade.go new file mode 100644 index 00000000..e8fb4dc4 --- /dev/null +++ b/internal/actions/upgrade.go @@ -0,0 +1,352 @@ +package actions + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + + "emperror.dev/errors" + "github.com/aviator-co/av/internal/config" + "github.com/aviator-co/av/internal/utils/colors" +) + +type InstallationMethod int + +const ( + InstallationMethodUnknown InstallationMethod = iota + InstallationMethodHomebrew + InstallationMethodAUR + InstallationMethodDeb + InstallationMethodRPM + InstallationMethodBinary +) + +// UpgradeCLI upgrades the av CLI to the latest version. +func UpgradeCLI(osName, arch string) error { + installedBy, err := DetectInstallationMethod() + if err != nil { + return fmt.Errorf("failed to detect installation method: %w", err) + } + + switch installedBy { + case InstallationMethodHomebrew: + fmt.Println(colors.CliCmd("Upgrading via Homebrew...")) + return upgradeHomebrew() + case InstallationMethodAUR: + fmt.Println(colors.CliCmd("Upgrading via AUR...")) + return upgradeAUR() + case InstallationMethodDeb: + fmt.Println(colors.CliCmd("Upgrading via apt...")) + return upgradeDeb() + case InstallationMethodRPM: + fmt.Println(colors.CliCmd("Upgrading via yum...")) + return upgradeRPM() + case InstallationMethodBinary: + fmt.Println(colors.CliCmd("Upgrading binary installation...")) + return upgradeBinary(osName, arch) + default: + return fmt.Errorf("unknown installation method") + } +} + +func upgradeHomebrew() error { + cmd := exec.Command("brew", "upgrade", "av") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func upgradeAUR() error { + // Try common AUR helpers + aurHelpers := []string{"yay", "paru", "pamac"} + for _, helper := range aurHelpers { + if path, err := exec.LookPath(helper); err == nil { + cmd := exec.Command(path, "-S", "av-cli-bin") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + } + return fmt.Errorf("no supported AUR helper found (tried: yay, paru, pamac)") +} + +func upgradeDeb() error { + latestVersion, err := config.FetchLatestVersion() + if err != nil { + return err + } + + // Download the .deb file + url := fmt.Sprintf("https://github.com/aviator-co/av/releases/download/%s/av_%s_linux_%s.deb", + latestVersion, latestVersion, runtime.GOARCH) + + fmt.Printf(colors.CliCmd("Downloading %s...\n"), url) + + tmpFile, err := downloadFile(url) + if err != nil { + return err + } + defer os.Remove(tmpFile) + + // Install the .deb file + cmd := exec.Command("sudo", "dpkg", "-i", tmpFile) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func upgradeRPM() error { + latestVersion, err := config.FetchLatestVersion() + if err != nil { + return err + } + + // Download the .rpm file + url := fmt.Sprintf("https://github.com/aviator-co/av/releases/download/%s/av_%s_linux_%s.rpm", + latestVersion, latestVersion, runtime.GOARCH) + + fmt.Printf(colors.CliCmd("Downloading %s...\n"), url) + + tmpFile, err := downloadFile(url) + if err != nil { + return err + } + defer os.Remove(tmpFile) + + // Install the .rpm file + cmd := exec.Command("sudo", "rpm", "-U", tmpFile) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// downloadFile downloads a file and returns the path to the temporary file +func downloadFile(url string) (string, error) { + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download file: %s", resp.Status) + } + + tmpFile, err := os.CreateTemp("", "av-upgrade-*") + if err != nil { + return "", err + } + defer tmpFile.Close() + + _, err = io.Copy(tmpFile, resp.Body) + if err != nil { + os.Remove(tmpFile.Name()) + return "", err + } + + return tmpFile.Name(), nil +} + +// DetectInstallationMethod determines how the av CLI was installed. +func DetectInstallationMethod() (InstallationMethod, error) { + executable, err := os.Executable() + if err != nil { + return InstallationMethodUnknown, fmt.Errorf("failed to determine current executable: %w", err) + } + + if strings.Contains(executable, "/Cellar/") || strings.Contains(executable, "/Homebrew/") { + return InstallationMethodHomebrew, nil + } + + if strings.Contains(executable, "/.cache/yay/") || strings.Contains(executable, "/pkg/") { + return InstallationMethodAUR, nil + } + + // Check for .deb package + if _, err := exec.LookPath("dpkg"); err == nil { + cmd := exec.Command("dpkg", "-S", executable) + if err := cmd.Run(); err == nil { + return InstallationMethodDeb, nil + } + } + + // Check for RPM package + if _, err := exec.LookPath("rpm"); err == nil { + cmd := exec.Command("rpm", "-qf", executable) + if err := cmd.Run(); err == nil { + return InstallationMethodRPM, nil + } + } + + return InstallationMethodBinary, nil +} + +// upgradeBinary handles the upgrade process for binary installations. +func upgradeBinary(osName, arch string) error { + latestVersion, err := config.FetchLatestVersion() + if err != nil { + return fmt.Errorf("failed to fetch latest version: %w", err) + } + + if config.Version == config.VersionDev { + return errors.New("cannot upgrade development version") + } + + if config.Version == latestVersion { + fmt.Println(colors.Success("You are already using the latest version.")) + return nil + } + + downloadURL, err := getDownloadURL(latestVersion, osName, arch) + if err != nil { + return err + } + + fmt.Printf(colors.CliCmd("Downloading %s...\n"), downloadURL) + + tmpFile, err := downloadFile(downloadURL) + if err != nil { + return err + } + defer os.Remove(tmpFile) + + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to find current executable: %w", err) + } + + fmt.Println(colors.CliCmd("Extracting and installing...")) + + // Create a backup of the current executable + backupPath := executable + ".backup" + if err := os.Rename(executable, backupPath); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + + // Extract and install the new version + if err := extractAndInstall(tmpFile, osName, executable); err != nil { + // Restore backup on failure + if restoreErr := os.Rename(backupPath, executable); restoreErr != nil { + return fmt.Errorf("failed to restore backup after failed upgrade: %w", restoreErr) + } + return err + } + + // Remove backup on success + os.Remove(backupPath) + + fmt.Printf(colors.Success("Successfully upgraded to version %s\n"), latestVersion) + return nil +} + +func extractAndInstall(archivePath, osName, targetPath string) error { + switch osName { + case "darwin", "linux": + return extractTarGzAndInstall(archivePath, targetPath) + case "windows": + return extractZipAndInstall(archivePath, targetPath) + default: + return fmt.Errorf("unsupported OS: %s", osName) + } +} + +func extractTarGzAndInstall(archivePath, targetPath string) error { + file, err := os.Open(archivePath) + if err != nil { + return err + } + defer file.Close() + + gzr, err := gzip.NewReader(file) + if err != nil { + return err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + if strings.HasSuffix(header.Name, "av") || strings.HasSuffix(header.Name, "av.exe") { + out, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, tr); err != nil { + return err + } + return nil + } + } + + return fmt.Errorf("executable not found in archive") +} + +func extractZipAndInstall(archivePath, targetPath string) error { + reader, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer reader.Close() + + for _, file := range reader.File { + if strings.HasSuffix(file.Name, "av.exe") { + rc, err := file.Open() + if err != nil { + return err + } + defer rc.Close() + + out, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, rc); err != nil { + return err + } + return nil + } + } + + return fmt.Errorf("av.exe not found in archive") +} + +func getDownloadURL(version, osName, arch string) (string, error) { + var fileExt, osArch string + switch osName { + case "darwin": + fileExt = "tar.gz" + osArch = "darwin" + case "linux": + fileExt = "tar.gz" + osArch = "linux" + case "windows": + fileExt = "zip" + osArch = "windows" + default: + return "", fmt.Errorf("unsupported OS: %s", osName) + } + + versionWithoutV := strings.TrimPrefix(version, "v") + + return fmt.Sprintf("https://github.com/aviator-co/av/releases/download/%s/av_%s_%s_%s.%s", + version, versionWithoutV, osArch, arch, fileExt), nil +}