Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ func setupCommands() {
rootCmd.AddCommand(NewLoginCmd())
rootCmd.AddCommand(NewGetCmd(c))
rootCmd.AddCommand(NewSetCmd())
rootCmd.AddCommand(NewUpdateCmd())
rootCmd.AddCommand(volumes.NewResetCmd(c))
rootCmd.AddCommand(volumes.NewCreateCmd(c))
rootCmd.AddCommand(volumes.NewUploadCmd(c))
Expand Down
283 changes: 283 additions & 0 deletions commands/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
package commands

import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"runtime"
"strings"

"github.com/fatih/color"
"github.com/spf13/cobra"

"github.com/shipyard/shipyard-cli/version"
)

const (
githubAPIBaseURL = "https://api.github.com"
repoOwner = "shipyard"
repoName = "shipyard-cli"
)

type GitHubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
Prerelease bool `json:"prerelease"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}

func NewUpdateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "update",
Short: "Update shipyard CLI to the latest version",
Long: `Check for the latest release on GitHub and update the CLI binary if a newer version is available.`,
RunE: runUpdate,
}

cmd.Flags().BoolP("force", "f", false, "Force update even if already on latest version")
cmd.Flags().BoolP("prerelease", "p", false, "Include prerelease versions")

return cmd
}

func runUpdate(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
includePrerelease, _ := cmd.Flags().GetBool("prerelease")

green := color.New(color.FgHiGreen)
yellow := color.New(color.FgHiYellow)
blue := color.New(color.FgHiBlue)

blue.Println("Checking for updates...")

// Get current version
currentVersion := version.Version
if currentVersion == "undefined" {
return fmt.Errorf("unable to determine current version")
}

// Fetch latest release from GitHub
latestRelease, err := getLatestRelease(includePrerelease)
if err != nil {
return fmt.Errorf("failed to fetch latest release: %w", err)
}

blue.Printf("Current version: %s\n", currentVersion)
blue.Printf("Latest version: %s\n", latestRelease.TagName)

// Check if update is needed
if !force && !isNewerVersion(currentVersion, latestRelease.TagName) {
green.Println("✓ You're already running the latest version!")
return nil
}

// Find the appropriate asset for the current platform
assetURL, err := findAssetForPlatform(latestRelease.Assets)
if err != nil {
return fmt.Errorf("failed to find compatible release asset: %w", err)
}

yellow.Printf("Downloading %s...\n", latestRelease.TagName)

// Download the new binary
tempFile, err := downloadBinary(assetURL)
if err != nil {
return fmt.Errorf("failed to download binary: %w", err)
}
defer os.Remove(tempFile)

// Get the current executable path
execPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
}

// Make the downloaded binary executable
if err := os.Chmod(tempFile, 0755); err != nil {
return fmt.Errorf("failed to make binary executable: %w", err)
}

// Create backup of current binary
backupPath := execPath + ".backup"
if err := copyFile(execPath, backupPath); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}

// Replace the current binary
if err := copyFile(tempFile, execPath); err != nil {
// Restore backup on failure
copyFile(backupPath, execPath)
return fmt.Errorf("failed to update binary: %w", err)
}

// Remove backup file
os.Remove(backupPath)

green.Printf("✓ Successfully updated to %s!\n", latestRelease.TagName)
blue.Println("Please restart your terminal or run 'shipyard --version' to verify the update.")

return nil
}

func getLatestRelease(includePrerelease bool) (*GitHubRelease, error) {
url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", githubAPIBaseURL, repoOwner, repoName)

client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}

// Add User-Agent header to avoid rate limiting
req.Header.Set("User-Agent", "shipyard-cli-updater")

resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}

var release GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}

// If we don't want prereleases and the latest is a prerelease, try to get the latest stable
if !includePrerelease && release.Prerelease {
// Get all releases and find the latest non-prerelease
url = fmt.Sprintf("%s/repos/%s/%s/releases", githubAPIBaseURL, repoOwner, repoName)
req, err = http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "shipyard-cli-updater")

resp, err = client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}

var releases []GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, err
}

// Find the first non-prerelease release
for _, r := range releases {
if !r.Prerelease {
release = r
break
}
}
}

return &release, nil
}

func findAssetForPlatform(assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}) (string, error) {
osName := runtime.GOOS
arch := runtime.GOARCH

// Map Go arch to common release asset arch names
archMap := map[string]string{
"amd64": "x86_64",
"arm64": "arm64",
"386": "i386",
}

releaseArch := archMap[arch]
if releaseArch == "" {
releaseArch = arch
}

// Look for asset matching our platform
expectedName := fmt.Sprintf("shipyard-%s-%s", osName, releaseArch)

for _, asset := range assets {
if strings.Contains(asset.Name, expectedName) {
return asset.BrowserDownloadURL, nil
}
}

// Fallback: look for any asset with our OS
for _, asset := range assets {
if strings.Contains(asset.Name, osName) {
return asset.BrowserDownloadURL, nil
}
}

return "", fmt.Errorf("no compatible release asset found for %s/%s", osName, arch)
}

func downloadBinary(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("download failed with status %d", resp.StatusCode)
}

// Create temporary file
tempFile, err := os.CreateTemp("", "shipyard-update-*")
if err != nil {
return "", err
}
defer tempFile.Close()

// Download to temp file
_, err = io.Copy(tempFile, resp.Body)
if err != nil {
os.Remove(tempFile.Name())
return "", err
}

return tempFile.Name(), nil
}

func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()

destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()

_, err = io.Copy(destFile, sourceFile)
return err
}

func isNewerVersion(current, latest string) bool {
// Remove 'v' prefix if present
current = strings.TrimPrefix(current, "v")
latest = strings.TrimPrefix(latest, "v")

// Simple version comparison - this could be enhanced with proper semver parsing
// For now, we'll do a basic string comparison
return latest > current
}
Loading