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: ¶ms.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: ¶ms.force, 174 }, 175 { 176 Name: "update", 177 Shorthand: "u", 178 Description: "Force update behaviour for the installer", 179 Value: ¶ms.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: ¶ms.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: ¶ms.path, 191 }, 192 { 193 Name: "version", // note: no shorthand because install.sh uses -v for selecting version 194 Value: ¶ms.showVersion, 195 }, 196 {Name: "non-interactive", Shorthand: "n", Hidden: true, Value: ¶ms.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: ¶ms.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 }