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 }