github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/cmd/state-installer/cmd.go (about)

     1  package main
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"runtime"
     9  	"runtime/debug"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/ActiveState/cli/internal/analytics"
    14  	"github.com/ActiveState/cli/internal/analytics/client/sync"
    15  	anaConst "github.com/ActiveState/cli/internal/analytics/constants"
    16  	"github.com/ActiveState/cli/internal/captain"
    17  	"github.com/ActiveState/cli/internal/config"
    18  	"github.com/ActiveState/cli/internal/constants"
    19  	"github.com/ActiveState/cli/internal/errs"
    20  	"github.com/ActiveState/cli/internal/events"
    21  	"github.com/ActiveState/cli/internal/fileutils"
    22  	"github.com/ActiveState/cli/internal/installation"
    23  	"github.com/ActiveState/cli/internal/installation/storage"
    24  	"github.com/ActiveState/cli/internal/locale"
    25  	"github.com/ActiveState/cli/internal/logging"
    26  	"github.com/ActiveState/cli/internal/multilog"
    27  	"github.com/ActiveState/cli/internal/osutils"
    28  	"github.com/ActiveState/cli/internal/output"
    29  	"github.com/ActiveState/cli/internal/primer"
    30  	"github.com/ActiveState/cli/internal/rollbar"
    31  	"github.com/ActiveState/cli/internal/runbits/errors"
    32  	"github.com/ActiveState/cli/internal/runbits/panics"
    33  	"github.com/ActiveState/cli/internal/subshell"
    34  	"github.com/ActiveState/cli/internal/subshell/bash"
    35  	"github.com/ActiveState/cli/pkg/project"
    36  	"github.com/ActiveState/cli/pkg/sysinfo"
    37  	"golang.org/x/term"
    38  )
    39  
    40  type Params struct {
    41  	sourceInstaller string
    42  	path            string
    43  	updateTag       string
    44  	command         string
    45  	force           bool
    46  	isUpdate        bool
    47  	activate        *project.Namespaced
    48  	activateDefault *project.Namespaced
    49  	showVersion     bool
    50  	nonInteractive  bool
    51  }
    52  
    53  func newParams() *Params {
    54  	return &Params{
    55  		activate:        &project.Namespaced{},
    56  		activateDefault: &project.Namespaced{},
    57  		nonInteractive:  !term.IsTerminal(int(os.Stdin.Fd())),
    58  	}
    59  }
    60  
    61  func main() {
    62  	var exitCode int
    63  
    64  	var an analytics.Dispatcher
    65  
    66  	var cfg *config.Instance
    67  
    68  	// Handle things like panics, exit codes and the closing of globals
    69  	defer func() {
    70  		if panics.HandlePanics(recover(), debug.Stack()) {
    71  			exitCode = 1
    72  		}
    73  
    74  		if cfg != nil {
    75  			events.Close("config", cfg.Close)
    76  		}
    77  
    78  		if err := events.WaitForEvents(5*time.Second, rollbar.Wait, an.Wait, logging.Close); err != nil {
    79  			logging.Warning("state-installer failed to wait for events: %v", err)
    80  		}
    81  		os.Exit(exitCode)
    82  	}()
    83  
    84  	// Set up verbose logging
    85  	logging.CurrentHandler().SetVerbose(os.Getenv("VERBOSE") != "")
    86  	// Set up rollbar reporting
    87  	rollbar.SetupRollbar(constants.StateInstallerRollbarToken)
    88  
    89  	// Allow starting the installer via a double click
    90  	captain.DisableMousetrap()
    91  
    92  	// Set up configuration handler
    93  	var err error
    94  	cfg, err = config.New()
    95  	if err != nil {
    96  		multilog.Error("Could not set up configuration handler: " + errs.JoinMessage(err))
    97  		fmt.Fprintln(os.Stderr, err.Error())
    98  		exitCode = 1
    99  	}
   100  
   101  	rollbar.SetConfig(cfg)
   102  
   103  	// Set up output handler
   104  	out, err := output.New("plain", &output.Config{
   105  		OutWriter:   os.Stdout,
   106  		ErrWriter:   os.Stderr,
   107  		Colored:     true,
   108  		Interactive: false,
   109  	})
   110  	if err != nil {
   111  		multilog.Error("Could not set up output handler: " + errs.JoinMessage(err))
   112  		fmt.Fprintln(os.Stderr, err.Error())
   113  		exitCode = 1
   114  		return
   115  	}
   116  
   117  	var garbageString string
   118  
   119  	// We have old install one liners around that use `-activate` instead of `--activate`
   120  	processedArgs := os.Args
   121  	for x, v := range processedArgs {
   122  		if strings.HasPrefix(v, "-activate") {
   123  			processedArgs[x] = "--activate" + strings.TrimPrefix(v, "-activate")
   124  		}
   125  	}
   126  
   127  	logging.Debug("Original Args: %v", os.Args)
   128  	logging.Debug("Processed Args: %v", processedArgs)
   129  
   130  	// Store sessionToken to config
   131  	for _, envVar := range []string{constants.OverrideSessionTokenEnvVarName, constants.SessionTokenEnvVarName} {
   132  		sessionToken, ok := os.LookupEnv(envVar)
   133  		if !ok {
   134  			continue
   135  		}
   136  		err := cfg.Set(anaConst.CfgSessionToken, sessionToken)
   137  		if err != nil {
   138  			multilog.Error("Unable to set session token: " + errs.JoinMessage(err))
   139  		}
   140  		break
   141  	}
   142  
   143  	an = sync.New(anaConst.SrcStateInstaller, cfg, nil, out)
   144  	an.Event(anaConst.CatInstallerFunnel, "start")
   145  
   146  	params := newParams()
   147  	cmd := captain.NewCommand(
   148  		"state-installer",
   149  		"",
   150  		"Installs or updates the State Tool",
   151  		primer.New(nil, out, nil, nil, nil, nil, cfg, nil, nil, an),
   152  		[]*captain.Flag{ // The naming of these flags is slightly inconsistent due to backwards compatibility requirements
   153  			{
   154  				Name:        "command",
   155  				Shorthand:   "c",
   156  				Description: "Run any command after the install script has completed",
   157  				Value:       &params.command,
   158  			},
   159  			{
   160  				Name:        "activate",
   161  				Description: "Activate a project when State Tool is correctly installed",
   162  				Value:       params.activate,
   163  			},
   164  			{
   165  				Name:        "activate-default",
   166  				Description: "Activate a project and make it always available for use",
   167  				Value:       params.activateDefault,
   168  			},
   169  			{
   170  				Name:        "force",
   171  				Shorthand:   "f",
   172  				Description: "Force the installation, overwriting any version of the State Tool already installed",
   173  				Value:       &params.force,
   174  			},
   175  			{
   176  				Name:        "update",
   177  				Shorthand:   "u",
   178  				Description: "Force update behaviour for the installer",
   179  				Value:       &params.isUpdate,
   180  			},
   181  			{
   182  				Name:   "source-installer",
   183  				Hidden: true, // This is internally routed in via the install frontend (eg. install.sh, etc)
   184  				Value:  &params.sourceInstaller,
   185  			},
   186  			{
   187  				Name:      "path",
   188  				Shorthand: "t",
   189  				Hidden:    true, // Since we already expose the path as an argument, let's not confuse the user
   190  				Value:     &params.path,
   191  			},
   192  			{
   193  				Name:  "version", // note: no shorthand because install.sh uses -v for selecting version
   194  				Value: &params.showVersion,
   195  			},
   196  			{Name: "non-interactive", Shorthand: "n", Hidden: true, Value: &params.nonInteractive}, // don't prompt
   197  			// The remaining flags are for backwards compatibility (ie. we don't want to error out when they're provided)
   198  			{Name: "channel", Hidden: true, Value: &garbageString},
   199  			{Name: "bbb", Shorthand: "b", Hidden: true, Value: &garbageString},
   200  			{Name: "vvv", Shorthand: "v", Hidden: true, Value: &garbageString},
   201  			{Name: "source-path", Hidden: true, Value: &garbageString},
   202  		},
   203  		[]*captain.Argument{
   204  			{
   205  				Name:        "path",
   206  				Description: "Install into target directory <path>",
   207  				Value:       &params.path,
   208  			},
   209  		},
   210  		func(ccmd *captain.Command, _ []string) error {
   211  			return execute(out, cfg, an, processedArgs[1:], params)
   212  		},
   213  	)
   214  
   215  	an.Event(anaConst.CatInstallerFunnel, "pre-exec")
   216  	err = cmd.Execute(processedArgs[1:])
   217  	if err != nil {
   218  		errors.ReportError(err, cmd, an)
   219  		if locale.IsInputError(err) {
   220  			an.EventWithLabel(anaConst.CatInstaller, "input-error", errs.JoinMessage(err))
   221  			logging.Debug("Installer input error: " + errs.JoinMessage(err))
   222  		} else {
   223  			an.EventWithLabel(anaConst.CatInstaller, "error", errs.JoinMessage(err))
   224  			multilog.Critical("Installer error: " + errs.JoinMessage(err))
   225  		}
   226  
   227  		an.EventWithLabel(anaConst.CatInstallerFunnel, "fail", errs.JoinMessage(err))
   228  		exitCode, err = errors.ParseUserFacing(err)
   229  		if err != nil {
   230  			out.Error(err)
   231  		}
   232  	} else {
   233  		an.Event(anaConst.CatInstallerFunnel, "success")
   234  	}
   235  }
   236  
   237  func execute(out output.Outputer, cfg *config.Instance, an analytics.Dispatcher, args []string, params *Params) error {
   238  	if params.showVersion {
   239  		vd := installation.VersionData{
   240  			"CLI Installer",
   241  			constants.LibraryLicense,
   242  			constants.Version,
   243  			constants.ChannelName,
   244  			constants.RevisionHash,
   245  			constants.Date,
   246  			constants.OnCI == "true",
   247  		}
   248  		out.Print(locale.T("version_info", vd))
   249  		return nil
   250  	}
   251  
   252  	an.Event(anaConst.CatInstallerFunnel, "exec")
   253  
   254  	if params.path == "" {
   255  		var err error
   256  		params.path, err = installation.InstallPathForChannel(constants.ChannelName)
   257  		if err != nil {
   258  			return errs.Wrap(err, "Could not detect installation path.")
   259  		}
   260  	}
   261  
   262  	// Detect installed state tool
   263  	stateToolInstalled, installPath, err := installedOnPath(params.path, constants.ChannelName)
   264  	if err != nil {
   265  		return errs.Wrap(err, "Could not detect if State Tool is already installed.")
   266  	}
   267  	if stateToolInstalled && installPath != params.path {
   268  		logging.Debug("Setting path to: %s", installPath)
   269  		params.path = installPath
   270  	}
   271  
   272  	// Detect if target dir is existing install of same target channel
   273  	var installedChannel string
   274  	marker := filepath.Join(installPath, installation.InstallDirMarker)
   275  	if stateToolInstalled && fileutils.TargetExists(marker) {
   276  		markerContents, err := fileutils.ReadFile(marker)
   277  		if err != nil {
   278  			return errs.Wrap(err, "Could not read marker file")
   279  		}
   280  		// The marker file is empty for versions prior to v0.40.0-RC3
   281  		if len(markerContents) > 0 {
   282  			var markerMeta installation.InstallMarkerMeta
   283  			if err := json.Unmarshal(markerContents, &markerMeta); err != nil {
   284  				return errs.Wrap(err, "Could not parse install marker file")
   285  			}
   286  			installedChannel = markerMeta.Channel
   287  		}
   288  	}
   289  	// Older state tools did not bake in meta information, in this case we allow overwriting regardless of channel
   290  	targetingSameChannel := installedChannel == "" || installedChannel == constants.ChannelName
   291  	stateToolInstalledAndFunctional := stateToolInstalled && installationIsOnPATH(params.path) && targetingSameChannel
   292  
   293  	// If this is a fresh installation we ensure that the target directory is empty
   294  	if !stateToolInstalled && fileutils.DirExists(params.path) && !params.force {
   295  		empty, err := fileutils.IsEmptyDir(params.path)
   296  		if err != nil {
   297  			return errs.Wrap(err, "Could not check if install path is empty")
   298  		}
   299  		if !empty {
   300  			return locale.NewInputError("err_install_nonempty_dir", "Installation path must be an empty directory: {{.V0}}", params.path)
   301  		}
   302  	}
   303  
   304  	// We expect the installer payload to be in the same directory as the installer itself
   305  	payloadPath := filepath.Dir(osutils.Executable())
   306  
   307  	route := "install"
   308  	if params.isUpdate {
   309  		route = "update"
   310  	}
   311  	an.Event(anaConst.CatInstallerFunnel, route)
   312  
   313  	// Check if state tool already installed and functional
   314  	if stateToolInstalledAndFunctional && !params.isUpdate && !params.force {
   315  		logging.Debug("Cancelling out because State Tool is already installed and functional")
   316  		out.Print(fmt.Sprintf("State Tool Package Manager is already installed at [NOTICE]%s[/RESET]. To reinstall use the [ACTIONABLE]--force[/RESET] flag.", installPath))
   317  		an.Event(anaConst.CatInstallerFunnel, "already-installed")
   318  		params.isUpdate = true
   319  		return postInstallEvents(out, cfg, an, params)
   320  	}
   321  
   322  	if err := installOrUpdateFromLocalSource(out, cfg, an, payloadPath, params); err != nil {
   323  		return err
   324  	}
   325  	storeInstallSource(params.sourceInstaller)
   326  	return postInstallEvents(out, cfg, an, params)
   327  }
   328  
   329  // installOrUpdateFromLocalSource is invoked when we're performing an installation where the payload is already provided
   330  func installOrUpdateFromLocalSource(out output.Outputer, cfg *config.Instance, an analytics.Dispatcher, payloadPath string, params *Params) error {
   331  	logging.Debug("Install from local source")
   332  	an.Event(anaConst.CatInstallerFunnel, "local-source")
   333  	if !params.isUpdate {
   334  		// install.sh or install.ps1 downloaded this installer and is running it.
   335  		out.Print(output.Title("Installing State Tool Package Manager"))
   336  		out.Print(`The State Tool lets you install and manage your language runtimes.` + "\n\n" +
   337  			`ActiveState collects usage statistics and diagnostic data about failures. ` + "\n" +
   338  			`By using the State Tool Package Manager you agree to the terms of ActiveState’s Privacy Policy, ` + "\n" +
   339  			`available at: [ACTIONABLE]https://www.activestate.com/company/privacy-policy[/RESET]` + "\n")
   340  	}
   341  
   342  	if err := assertCompatibility(); err != nil {
   343  		// Don't wrap, we want the error from assertCompatibility to be returned -- installer doesn't have intelligent error handling yet
   344  		// https://activestatef.atlassian.net/browse/DX-957
   345  		return err
   346  	}
   347  
   348  	installer, err := NewInstaller(cfg, out, an, payloadPath, params)
   349  	if err != nil {
   350  		out.Print(fmt.Sprintf("[ERROR]Could not create installer: %s[/RESET]", errs.JoinMessage(err)))
   351  		return err
   352  	}
   353  
   354  	if params.isUpdate {
   355  		out.Fprint(os.Stdout, "• Installing Update... ")
   356  	} else {
   357  		out.Fprint(os.Stdout, fmt.Sprintf("• Installing State Tool to [NOTICE]%s[/RESET]... ", installer.InstallPath()))
   358  	}
   359  
   360  	// Run installer
   361  	an.Event(anaConst.CatInstallerFunnel, "pre-installer")
   362  	if err := installer.Install(); err != nil {
   363  		out.Print("[ERROR]x Failed[/RESET]")
   364  		return err
   365  	}
   366  	an.Event(anaConst.CatInstallerFunnel, "post-installer")
   367  	out.Print("[SUCCESS]✔ Done[/RESET]")
   368  
   369  	if !params.isUpdate {
   370  		out.Print("")
   371  		out.Print(output.Title("State Tool Package Manager Installation Complete"))
   372  		out.Print("State Tool Package Manager has been successfully installed.")
   373  	}
   374  
   375  	return nil
   376  }
   377  
   378  func postInstallEvents(out output.Outputer, cfg *config.Instance, an analytics.Dispatcher, params *Params) error {
   379  	an.Event(anaConst.CatInstallerFunnel, "post-install-events")
   380  
   381  	installPath, err := resolveInstallPath(params.path)
   382  	if err != nil {
   383  		return errs.Wrap(err, "Could not resolve installation path")
   384  	}
   385  
   386  	binPath, err := installation.BinPathFromInstallPath(installPath)
   387  	if err != nil {
   388  		return errs.Wrap(err, "Could not detect installation bin path")
   389  	}
   390  
   391  	stateExe, err := installation.StateExecFromDir(installPath)
   392  	if err != nil {
   393  		return locale.WrapError(err, "err_state_exec")
   394  	}
   395  
   396  	ss := subshell.New(cfg)
   397  	if ss.Shell() == bash.Name && runtime.GOOS == "darwin" {
   398  		out.Print(locale.T("warning_macos_bash"))
   399  	}
   400  
   401  	// Execute requested command, these are mutually exclusive
   402  	switch {
   403  	// Execute provided --command
   404  	case params.command != "":
   405  		an.Event(anaConst.CatInstallerFunnel, "forward-command")
   406  
   407  		out.Print(fmt.Sprintf("\nRunning '[ACTIONABLE]%s[/RESET]'\n", params.command))
   408  		cmd, args := osutils.DecodeCmd(params.command)
   409  		if _, _, err := osutils.ExecuteAndPipeStd(cmd, args, envSlice(binPath)); err != nil {
   410  			an.EventWithLabel(anaConst.CatInstallerFunnel, "forward-command-err", err.Error())
   411  			return errs.Silence(errs.Wrap(err, "Running provided command failed, error returned: %s", errs.JoinMessage(err)))
   412  		}
   413  	// Activate provided --activate Namespace
   414  	case params.activate.IsValid():
   415  		an.Event(anaConst.CatInstallerFunnel, "forward-activate")
   416  
   417  		out.Print(fmt.Sprintf("\nRunning '[ACTIONABLE]state activate %s[/RESET]'\n", params.activate.String()))
   418  		if _, _, err := osutils.ExecuteAndPipeStd(stateExe, []string{"activate", params.activate.String()}, envSlice(binPath)); err != nil {
   419  			an.EventWithLabel(anaConst.CatInstallerFunnel, "forward-activate-err", err.Error())
   420  			return errs.Silence(errs.Wrap(err, "Could not activate %s, error returned: %s", params.activate.String(), errs.JoinMessage(err)))
   421  		}
   422  	// Activate provided --activate-default Namespace
   423  	case params.activateDefault.IsValid():
   424  		an.Event(anaConst.CatInstallerFunnel, "forward-activate-default")
   425  
   426  		out.Print(fmt.Sprintf("\nRunning '[ACTIONABLE]state activate --default %s[/RESET]'\n", params.activateDefault.String()))
   427  		if _, _, err := osutils.ExecuteAndPipeStd(stateExe, []string{"activate", params.activateDefault.String(), "--default"}, envSlice(binPath)); err != nil {
   428  			an.EventWithLabel(anaConst.CatInstallerFunnel, "forward-activate-default-err", err.Error())
   429  			return errs.Silence(errs.Wrap(err, "Could not activate %s, error returned: %s", params.activateDefault.String(), errs.JoinMessage(err)))
   430  		}
   431  	case !params.isUpdate && term.IsTerminal(int(os.Stdin.Fd())) && os.Getenv(constants.InstallerNoSubshell) != "true" && os.Getenv("TERM") != "dumb":
   432  		if err := ss.SetEnv(osutils.InheritEnv(envMap(binPath))); err != nil {
   433  			return locale.WrapError(err, "err_subshell_setenv")
   434  		}
   435  		if err := ss.Activate(nil, cfg, out); err != nil {
   436  			return errs.Wrap(err, "Error activating subshell: %s", errs.JoinMessage(err))
   437  		}
   438  		if err = <-ss.Errors(); err != nil && !errs.IsSilent(err) {
   439  			return errs.Wrap(err, "Error during subshell execution: %s", errs.JoinMessage(err))
   440  		}
   441  	}
   442  
   443  	return nil
   444  }
   445  
   446  func envSlice(binPath string) []string {
   447  	return []string{
   448  		"PATH=" + binPath + string(os.PathListSeparator) + os.Getenv("PATH"),
   449  		constants.DisableErrorTipsEnvVarName + "=true",
   450  	}
   451  }
   452  
   453  func envMap(binPath string) map[string]string {
   454  	return map[string]string{
   455  		"PATH": binPath + string(os.PathListSeparator) + os.Getenv("PATH"),
   456  	}
   457  }
   458  
   459  // storeInstallSource writes the name of the install client (eg. install.sh) to the appdata dir
   460  // this is used in analytics to give us a sense for where our users are coming from
   461  func storeInstallSource(installSource string) {
   462  	if installSource == "" {
   463  		installSource = "state-installer"
   464  	}
   465  
   466  	appData, err := storage.AppDataPath()
   467  	if err != nil {
   468  		multilog.Error("Could not store install source due to AppDataPath error: %s", errs.JoinMessage(err))
   469  		return
   470  	}
   471  	if err := fileutils.WriteFile(filepath.Join(appData, constants.InstallSourceFile), []byte(installSource)); err != nil {
   472  		multilog.Error("Could not store install source due to WriteFile error: %s", errs.JoinMessage(err))
   473  	}
   474  }
   475  
   476  func resolveInstallPath(path string) (string, error) {
   477  	if path != "" {
   478  		return filepath.Abs(path)
   479  	} else {
   480  		return installation.DefaultInstallPath()
   481  	}
   482  }
   483  
   484  func assertCompatibility() error {
   485  	if sysinfo.OS() == sysinfo.Windows {
   486  		osv, err := sysinfo.OSVersion()
   487  		if err != nil {
   488  			return locale.WrapError(err, "windows_compatibility_warning", "", err.Error())
   489  		} else if osv.Major < 10 || (osv.Major == 10 && osv.Micro < 17134) {
   490  			return locale.WrapError(err, "windows_compatibility_error", "", osv.Name, osv.Version)
   491  		}
   492  	}
   493  
   494  	return nil
   495  }