github.com/fastly/cli@v1.7.2-0.20240304164155-9d0f1d77c3bf/pkg/commands/install/root.go (about)

     1  package install
     2  
     3  import (
     4  	"fmt"
     5  	"io"
     6  	"os"
     7  	"path/filepath"
     8  
     9  	"github.com/fastly/cli/pkg/argparser"
    10  	"github.com/fastly/cli/pkg/filesystem"
    11  	"github.com/fastly/cli/pkg/global"
    12  	"github.com/fastly/cli/pkg/text"
    13  )
    14  
    15  // RootCommand is the parent command for all subcommands in this package.
    16  // It should be installed under the primary root command.
    17  type RootCommand struct {
    18  	argparser.Base
    19  
    20  	versionToInstall string
    21  }
    22  
    23  // NewRootCommand returns a new command registered in the parent.
    24  func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand {
    25  	var c RootCommand
    26  	c.Globals = g
    27  	c.CmdClause = parent.Command("install", "Install the specified version of the CLI")
    28  	c.CmdClause.Arg("version", "CLI release version to install (e.g. 10.8.0)").Required().StringVar(&c.versionToInstall)
    29  	return &c
    30  }
    31  
    32  // Exec implements the command interface.
    33  func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error {
    34  	spinner, err := text.NewSpinner(out)
    35  	if err != nil {
    36  		return err
    37  	}
    38  
    39  	var downloadedBin string
    40  	err = spinner.Process(fmt.Sprintf("Fetching release %s", c.versionToInstall), func(_ *text.SpinnerWrapper) error {
    41  		downloadedBin, err = c.Globals.Versioners.CLI.DownloadVersion(c.versionToInstall)
    42  		if err != nil {
    43  			c.Globals.ErrLog.AddWithContext(err, map[string]any{
    44  				"CLI version to install": c.versionToInstall,
    45  			})
    46  			return fmt.Errorf("error downloading release version %s: %w", c.versionToInstall, err)
    47  		}
    48  		return nil
    49  	})
    50  	if err != nil {
    51  		return err
    52  	}
    53  	defer os.RemoveAll(downloadedBin)
    54  
    55  	var currentBin string
    56  	err = spinner.Process("Replacing binary", func(_ *text.SpinnerWrapper) error {
    57  		execPath, err := os.Executable()
    58  		if err != nil {
    59  			c.Globals.ErrLog.Add(err)
    60  			return fmt.Errorf("error determining executable path: %w", err)
    61  		}
    62  
    63  		currentBin, err = filepath.Abs(execPath)
    64  		if err != nil {
    65  			c.Globals.ErrLog.AddWithContext(err, map[string]any{
    66  				"Executable path": execPath,
    67  			})
    68  			return fmt.Errorf("error determining absolute target path: %w", err)
    69  		}
    70  
    71  		// Windows does not permit replacing a running executable, however it will
    72  		// permit it if you first move the original executable. So we first move the
    73  		// running executable to a new location, then we move the executable that we
    74  		// downloaded to the same location as the original.
    75  		// I've also tested this approach on nix systems and it works fine.
    76  		//
    77  		// Reference:
    78  		// https://github.com/golang/go/issues/21997#issuecomment-331744930
    79  
    80  		backup := currentBin + ".bak"
    81  		if err := os.Rename(currentBin, backup); err != nil {
    82  			c.Globals.ErrLog.AddWithContext(err, map[string]any{
    83  				"Executable (source)":      downloadedBin,
    84  				"Executable (destination)": currentBin,
    85  			})
    86  			return fmt.Errorf("error moving the current executable: %w", err)
    87  		}
    88  
    89  		if err = os.Remove(backup); err != nil {
    90  			c.Globals.ErrLog.Add(err)
    91  		}
    92  
    93  		// Move the downloaded binary to the same location as the current executable.
    94  		if err := os.Rename(downloadedBin, currentBin); err != nil {
    95  			c.Globals.ErrLog.AddWithContext(err, map[string]any{
    96  				"Executable (source)":      downloadedBin,
    97  				"Executable (destination)": currentBin,
    98  			})
    99  			renameErr := err
   100  
   101  			// Failing that we'll try to io.Copy downloaded binary to the current binary.
   102  			if err := filesystem.CopyFile(downloadedBin, currentBin); err != nil {
   103  				c.Globals.ErrLog.AddWithContext(err, map[string]any{
   104  					"Executable (source)":      downloadedBin,
   105  					"Executable (destination)": currentBin,
   106  				})
   107  				return fmt.Errorf("error 'copying' latest binary in place: %w (following an error 'moving': %w)", err, renameErr)
   108  			}
   109  		}
   110  		return nil
   111  	})
   112  	if err != nil {
   113  		return err
   114  	}
   115  
   116  	text.Success(out, "\nInstalled version %s.", c.versionToInstall)
   117  	return nil
   118  }