github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/subshell/subshell.go (about)

     1  package subshell
     2  
     3  import (
     4  	"os"
     5  	"os/exec"
     6  	"path/filepath"
     7  	"runtime"
     8  	"strings"
     9  
    10  	"github.com/thoas/go-funk"
    11  
    12  	"github.com/ActiveState/cli/internal/constants"
    13  	"github.com/ActiveState/cli/internal/errs"
    14  	"github.com/ActiveState/cli/internal/fileutils"
    15  	"github.com/ActiveState/cli/internal/logging"
    16  	"github.com/ActiveState/cli/internal/multilog"
    17  	"github.com/ActiveState/cli/internal/osutils"
    18  	"github.com/ActiveState/cli/internal/output"
    19  	"github.com/ActiveState/cli/internal/rollbar"
    20  	"github.com/ActiveState/cli/internal/subshell/bash"
    21  	"github.com/ActiveState/cli/internal/subshell/cmd"
    22  	"github.com/ActiveState/cli/internal/subshell/fish"
    23  	"github.com/ActiveState/cli/internal/subshell/sscommon"
    24  	"github.com/ActiveState/cli/internal/subshell/tcsh"
    25  	"github.com/ActiveState/cli/internal/subshell/zsh"
    26  	"github.com/ActiveState/cli/pkg/project"
    27  )
    28  
    29  const ConfigKeyShell = "shell"
    30  
    31  // SubShell defines the interface for our virtual environment packages, which should be contained in a sub-directory
    32  // under the same directory as this file
    33  type SubShell interface {
    34  	// Activate the given subshell
    35  	Activate(proj *project.Project, cfg sscommon.Configurable, out output.Outputer) error
    36  
    37  	// Errors returns a channel to receive errors
    38  	Errors() <-chan error
    39  
    40  	// Deactivate the given subshell
    41  	Deactivate() error
    42  
    43  	// Run a script string, passing the provided command-line arguments, that assumes this shell and returns the exit code
    44  	Run(filename string, args ...string) error
    45  
    46  	// IsActive returns whether the given subshell is active
    47  	IsActive() bool
    48  
    49  	// Binary returns the configured binary
    50  	Binary() string
    51  
    52  	// SetBinary sets the configured binary, this should only be called by the subshell package
    53  	SetBinary(string)
    54  
    55  	// WriteUserEnv writes the given env map to the users environment
    56  	WriteUserEnv(sscommon.Configurable, map[string]string, sscommon.RcIdentification, bool) error
    57  
    58  	// CleanUserEnv removes the environment setting identified
    59  	CleanUserEnv(sscommon.Configurable, sscommon.RcIdentification, bool) error
    60  
    61  	// RemoveLegacyInstallPath removes the install path added to shell configuration by the legacy install scripts
    62  	RemoveLegacyInstallPath(sscommon.Configurable) error
    63  
    64  	// WriteCompletionScript writes the completions script for the current shell
    65  	WriteCompletionScript(string) error
    66  
    67  	// RcFile return the path of the RC file
    68  	RcFile() (string, error)
    69  
    70  	// EnsureRcFile ensures that the RC file exists
    71  	EnsureRcFileExists() error
    72  
    73  	// SetupShellRcFile writes a script or source-able file that updates the environment variables and sets the prompt
    74  	SetupShellRcFile(string, map[string]string, *project.Namespaced, sscommon.Configurable) error
    75  
    76  	// Shell returns an identifiable string representing the shell, eg. bash, zsh
    77  	Shell() string
    78  
    79  	// SetEnv sets the environment up for the given subshell
    80  	SetEnv(env map[string]string) error
    81  
    82  	// Quote will quote the given string, escaping any characters that need escaping
    83  	Quote(value string) string
    84  
    85  	// IsAvailable returns whether the shell is available on the system
    86  	IsAvailable() bool
    87  }
    88  
    89  // New returns the subshell relevant to the current process, but does not activate it
    90  func New(cfg sscommon.Configurable) SubShell {
    91  	name, path := DetectShell(cfg)
    92  
    93  	var subs SubShell
    94  	switch name {
    95  	case bash.Name:
    96  		subs = &bash.SubShell{}
    97  	case zsh.Name:
    98  		subs = &zsh.SubShell{}
    99  	case tcsh.Name:
   100  		subs = &tcsh.SubShell{}
   101  	case fish.Name:
   102  		subs = &fish.SubShell{}
   103  	case cmd.Name:
   104  		subs = &cmd.SubShell{}
   105  	default:
   106  		rollbar.Error("subshell.DetectShell did not return a known name: %s", name)
   107  		switch runtime.GOOS {
   108  		case "windows":
   109  			subs = &cmd.SubShell{}
   110  		case "darwin":
   111  			subs = &zsh.SubShell{}
   112  		default:
   113  			subs = &bash.SubShell{}
   114  		}
   115  	}
   116  
   117  	logging.Debug("Using binary: %s", path)
   118  	subs.SetBinary(path)
   119  
   120  	env := funk.FilterString(os.Environ(), func(s string) bool {
   121  		return !strings.HasPrefix(s, constants.ProjectEnvVarName)
   122  	})
   123  	err := subs.SetEnv(osutils.EnvSliceToMap(env))
   124  	if err != nil {
   125  		// We cannot error here, but this error will resurface when activating a runtime, so we can
   126  		// notify the user at that point.
   127  		logging.Error("Failed to set subshell environment: %v", err)
   128  	}
   129  
   130  	return subs
   131  }
   132  
   133  // resolveBinaryPath tries to find the named binary on PATH
   134  func resolveBinaryPath(name string) string {
   135  	binaryPath, err := exec.LookPath(name)
   136  	if err == nil {
   137  		// if we found it, resolve all symlinks, for many Linux distributions the SHELL is "sh" but symlinked to a different default shell like bash or zsh
   138  		resolved, err := fileutils.ResolvePath(binaryPath)
   139  		if err == nil {
   140  			return resolved
   141  		} else {
   142  			logging.Debug("Failed to resolve path to shell binary %s: %v", binaryPath, err)
   143  		}
   144  	}
   145  	return name
   146  }
   147  
   148  func ConfigureAvailableShells(shell SubShell, cfg sscommon.Configurable, env map[string]string, identifier sscommon.RcIdentification, userScope bool) error {
   149  	// Ensure the given, detected, and current shell has an RC file or else it will not be considered "available"
   150  	err := shell.EnsureRcFileExists()
   151  	if err != nil {
   152  		return errs.Wrap(err, "Could not ensure RC file for current shell")
   153  	}
   154  
   155  	for _, s := range supportedShells {
   156  		if !s.IsAvailable() {
   157  			continue
   158  		}
   159  		err := s.WriteUserEnv(cfg, env, identifier, userScope)
   160  		if err != nil {
   161  			logging.Error("Could not update PATH for shell %s: %v", s.Shell(), err)
   162  		}
   163  	}
   164  
   165  	return nil
   166  }
   167  
   168  // DetectShell detects the shell relevant to the current process and returns its name and path.
   169  func DetectShell(cfg sscommon.Configurable) (string, string) {
   170  	configured := cfg.GetString(ConfigKeyShell)
   171  	var binary string
   172  	defer func() {
   173  		// do not re-write shell binary to config, if the value did not change.
   174  		if configured == binary {
   175  			return
   176  		}
   177  		// We save and use the detected shell to our config so that we can use it when running code through
   178  		// a non-interactive shell
   179  		if err := cfg.Set(ConfigKeyShell, binary); err != nil {
   180  			multilog.Error("Could not save shell binary: %v", errs.JoinMessage(err))
   181  		}
   182  	}()
   183  
   184  	binary = os.Getenv("SHELL")
   185  	if binary == "" && runtime.GOOS == "windows" {
   186  		binary = os.Getenv("ComSpec")
   187  	}
   188  
   189  	if binary == "" {
   190  		binary = configured
   191  	}
   192  	if binary == "" {
   193  		if runtime.GOOS == "windows" {
   194  			binary = "cmd.exe"
   195  		} else {
   196  			binary = "bash"
   197  		}
   198  	}
   199  
   200  	path := resolveBinaryPath(binary)
   201  
   202  	name := filepath.Base(path)
   203  	name = strings.TrimSuffix(name, filepath.Ext(name))
   204  	logging.Debug("Detected SHELL: %s", name)
   205  
   206  	if runtime.GOOS == "windows" {
   207  		// For some reason Go or MSYS doesn't translate paths with spaces correctly, so we have to strip out the
   208  		// invalid escape characters for spaces
   209  		path = strings.ReplaceAll(path, `\ `, ` `)
   210  	}
   211  
   212  	isKnownShell := false
   213  	for _, ssName := range []string{bash.Name, cmd.Name, fish.Name, tcsh.Name, zsh.Name} {
   214  		if name == ssName {
   215  			isKnownShell = true
   216  			break
   217  		}
   218  	}
   219  
   220  	if !isKnownShell {
   221  		logging.Debug("Unsupported shell: %s, defaulting to OS default.", name)
   222  		if !strings.EqualFold(name, "powershell") && name != "sh" {
   223  			rollbar.Error("Unsupported shell: %s", name) // we just want to know what this person is using
   224  		}
   225  		switch runtime.GOOS {
   226  		case "windows":
   227  			name = cmd.Name
   228  			path = resolveBinaryPath("cmd.exe")
   229  		case "darwin":
   230  			name = zsh.Name
   231  			path = resolveBinaryPath("zsh")
   232  		default:
   233  			name = bash.Name
   234  			path = resolveBinaryPath("bash")
   235  		}
   236  	}
   237  
   238  	return name, path
   239  }