Skip to content

Commit

Permalink
feat: Impl update command (#35)
Browse files Browse the repository at this point in the history
* feat: Impl update command

* Fix unit test by use mock server

* Fix test

* Mock network call
  • Loading branch information
XiaoConstantine authored Jul 10, 2024
1 parent dc0d6d7 commit bbbadf2
Show file tree
Hide file tree
Showing 9 changed files with 485 additions and 8 deletions.
11 changes: 7 additions & 4 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ builds:
- arm64
main: ./cmd/main.go
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- -s -w
- -X github.com/XiaoConstantine/mycli/pkg/build.Version={{.Version}}
- -X github.com/XiaoConstantine/mycli/pkg/build.Commit={{.ShortCommit}}
- -X github.com/XiaoConstantine/mycli/pkg/build.Date={{.Date}}

archives:
- format: tar.gz
name_template: >-
{{ .ProjectName }}_v
{{ .ProjectName }}_
{{- .Version }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
Expand All @@ -29,7 +32,7 @@ checksum:
name_template: "checksums.txt"

snapshot:
name_template: "{{ .Tag }}-next"
name_template: "{{ incpatch .Version }}-next"

changelog:
sort: asc
Expand All @@ -44,6 +47,6 @@ release:
github:
owner: XiaoConstantine
name: mycli
prerelease: "false"
prerelease: auto

dist: clean
6 changes: 6 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ Copyright © 2024 Xiao Cui <[email protected]>
package main

import (
"fmt"
"os"

"github.com/XiaoConstantine/mycli/pkg/build"
"github.com/XiaoConstantine/mycli/pkg/commands/root"
)

func main() {
if len(os.Args) > 1 && os.Args[1] == "version" {
fmt.Printf("mycli version %s (%s) - built on %s\n", build.Version, build.Commit, build.Date)
os.Exit(0)
}
code := root.Run([]string{})
os.Exit(int(code))
}
10 changes: 10 additions & 0 deletions cmd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,20 @@ import (
"testing"

"github.com/XiaoConstantine/mycli/pkg/commands/root"
"github.com/XiaoConstantine/mycli/pkg/commands/update"
"github.com/XiaoConstantine/mycli/pkg/iostreams"
"github.com/stretchr/testify/assert"
)

func TestRun(t *testing.T) {
// Save the original function and defer its restoration
originalCheckForUpdates := update.CheckForUpdatesFunc
defer func() { update.CheckForUpdatesFunc = originalCheckForUpdates }()

// Mock the CheckForUpdates function
update.CheckForUpdatesFunc = func(iostream *iostreams.IOStreams) (bool, string, error) {
return false, "v1.0.0", nil // No update available
}
// Define test cases
tests := []struct {
name string
Expand Down
12 changes: 12 additions & 0 deletions pkg/build/build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package build

var (
// Version is the current version of the CLI.
Version = "dev"

// Commit is the git commit hash of the build.
Commit = "none"

// Date is the build date.
Date = "unknown"
)
28 changes: 24 additions & 4 deletions pkg/commands/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
"errors"
"fmt"

"github.com/XiaoConstantine/mycli/pkg/build"
"github.com/XiaoConstantine/mycli/pkg/commands/install"
"github.com/XiaoConstantine/mycli/pkg/commands/update"
"github.com/XiaoConstantine/mycli/pkg/iostreams"
"github.com/XiaoConstantine/mycli/pkg/utils"

Expand Down Expand Up @@ -45,6 +47,7 @@ func NewRootCmd(iostream *iostreams.IOStreams) (*cobra.Command, error) {
Long: `Internal CLI help bootstrap my machine.`,
SilenceErrors: true,
SilenceUsage: true,
Version: fmt.Sprintf("%s (%s) - built on %s", build.Version, build.Commit, build.Date),
}
rootCmd.AddGroup(
&cobra.Group{
Expand All @@ -58,11 +61,18 @@ func NewRootCmd(iostream *iostreams.IOStreams) (*cobra.Command, error) {
Title: "Configure commands",
})

rootCmd.AddGroup(&cobra.Group{
ID: "update",
Title: "Update command",
})

installCmd := install.NewInstallCmd(iostream)
configureCmd := configure.NewConfigureCmd(iostream)
updateCmd := update.NewUpdateCmd(iostream)

rootCmd.AddCommand(installCmd)
rootCmd.AddCommand(configureCmd)
rootCmd.AddCommand(updateCmd)
rootCmd.PersistentFlags().Bool("help", false, "Show help for command")
rootCmd.PersistentFlags().BoolVar(&nonInteractive, "non-interactive", false, "Run in non-interactive mode")

Expand All @@ -76,6 +86,19 @@ func Run(args []string) ExitCode {

utils.PrintWelcomeMessage(iostream)
rootCmd, err := NewRootCmd(iostream)
if err != nil {
fmt.Fprintf(iostream.ErrOut, "Failed to create root command")
return exitError
}

// Check for updates
hasUpdate, latestVersion, err := update.CheckForUpdatesFunc(iostream)
if err != nil {
fmt.Fprintf(stderr, "Failed to check for updates: %s\n", err)
} else if hasUpdate {
fmt.Fprintf(iostream.Out, "A new version of mycli is available: %s (current: %s)\n", latestVersion, build.Version)
fmt.Fprintf(iostream.Out, "Run 'mycli update' to update\n\n")
}

os_info := utils.GetOsInfo()
// todo: make optional for tracing
Expand Down Expand Up @@ -134,10 +157,7 @@ func Run(args []string) ExitCode {
}
rootCmd.SetArgs([]string{selectedOption})
}
if err != nil {
fmt.Fprintf(stderr, "failed to create root command: %s\n", err)
return exitError
}

if _, err := rootCmd.ExecuteContextC(ctx); err != nil {
var pagerPipeError *iostreams.ErrClosedPagerPipe
var noResultsError utils.NoResultsError
Expand Down
199 changes: 199 additions & 0 deletions pkg/commands/update/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package update

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

"github.com/XiaoConstantine/mycli/pkg/build"
"github.com/XiaoConstantine/mycli/pkg/iostreams"
"github.com/XiaoConstantine/mycli/pkg/utils"
"github.com/spf13/cobra"
)

var CheckForUpdatesFunc = CheckForUpdates

func NewUpdateCmd(iostream *iostreams.IOStreams) *cobra.Command {
cmd := &cobra.Command{
Use: "update",
Short: "Update mycli to the latest version",
RunE: func(cmd *cobra.Command, args []string) error {
installDir, err := ensureInstallDirectory()
if err != nil {
return err
}

if err := ensurePathInZshrc(installDir); err != nil {
return err
}

return updateCLI(iostream)
},
}
return cmd
}

func updateCLI(iostream *iostreams.IOStreams) error {
currentVersion := build.Version

// Ensure .mycli/bin directory exists
installDir, err := ensureInstallDirectory()
if err != nil {
return fmt.Errorf("failed to ensure install directory: %w", err)
}

// Get the latest release info
release, err := getLatestRelease()
if err != nil {
return fmt.Errorf("failed to get latest release: %w", err)
}

// Check if update is needed
if utils.CompareVersions(currentVersion, release.TagName) >= 0 {
fmt.Fprintln(iostream.Out, "You're already using the latest version of mycli.")
return nil
}

// Determine the asset to download based on the current OS and architecture
assetURL := ""
for _, asset := range release.Assets {
if asset.Name == fmt.Sprintf("mycli_%s_%s", runtime.GOOS, runtime.GOARCH) {
assetURL = asset.BrowserDownloadURL
break
}
}

if assetURL == "" {
return fmt.Errorf("no suitable release found for %s/%s", runtime.GOOS, runtime.GOARCH)
}

// Download the new binary
resp, err := http.Get(assetURL)
if err != nil {
return fmt.Errorf("failed to download update: %w", err)
}
defer resp.Body.Close()

// Determine the install location
installPath := filepath.Join(installDir, "mycli")

// Create a temporary file
tmpFile, err := os.CreateTemp("", "mycli-update")
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
defer os.Remove(tmpFile.Name())

// Copy the downloaded content to the temporary file
_, err = io.Copy(tmpFile, resp.Body)
if err != nil {
return fmt.Errorf("failed to write update to temporary file: %w", err)
}
tmpFile.Close()

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

// Replace the old binary with the new one
if err := os.Rename(tmpFile.Name(), installPath); err != nil {
return fmt.Errorf("failed to replace old binary: %w", err)
}

fmt.Fprintf(iostream.Out, "mycli has been updated successfully to version %s!\n", release.TagName)
return nil
}

var ensureInstallDirectory = func() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get user home directory: %w", err)
}

installDir := filepath.Join(home, ".mycli", "bin")
if err := os.MkdirAll(installDir, 0755); err != nil {
return "", fmt.Errorf("failed to create install directory: %w", err)
}

return installDir, nil
}

func ensurePathInZshrc(installDir string) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}

zshrcPath := filepath.Join(home, ".zshrc")
zshrcContent, err := os.ReadFile(zshrcPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to read .zshrc: %w", err)
}

pathLine := fmt.Sprintf("export PATH=\"$PATH:%s\"", installDir)
if !strings.Contains(string(zshrcContent), pathLine) {
f, err := os.OpenFile(zshrcPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open .zshrc: %w", err)
}
defer f.Close()

if _, err := f.WriteString("\n" + pathLine + "\n"); err != nil {
return fmt.Errorf("failed to write to .zshrc: %w", err)
}

fmt.Println("Added .mycli/bin to your PATH in .zshrc. Please restart your terminal or run 'source ~/.zshrc' to apply the changes.")
}

return nil
}

const repoURL = "https://api.github.com/repos/XiaoConstantine/mycli/releases/latest"

type githubRelease struct {
TagName string `json:"tag_name"`
Assets []githubAsset `json:"assets"`
}

type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}

var getLatestRelease = func() (*githubRelease, error) {
resp, err := http.Get(repoURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch latest release: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to decode release JSON: %w", err)
}

return &release, nil
}

func CheckForUpdates(iostream *iostreams.IOStreams) (bool, string, error) {
currentVersion := build.Version

// Get the latest release info
release, err := getLatestRelease()
if err != nil {
return false, "", fmt.Errorf("failed to get latest release: %w", err)
}

hasUpdate := utils.CompareVersions(currentVersion, release.TagName) < 0
return hasUpdate, release.TagName, nil
}
Loading

0 comments on commit bbbadf2

Please sign in to comment.