diff --git a/INSTALL.md b/INSTALL.md index 61d32763..0ae4bf3d 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -20,9 +20,24 @@ Alternatively, you can install the CLI via [shell script](#linuxmacosbsd-shell-s ## Windows +### Winget + +Using winget is recommended: + +```sh +$ winget install doppler +$ doppler --version +``` + +To update: + +```sh +$ winget upgrade doppler +``` + ### Scoop -Using [scoop](https://scoop.sh/) is recommended: +Using [scoop](https://scoop.sh/) is supported: ```sh $ scoop bucket add doppler https://github.com/DopplerHQ/scoop-doppler.git diff --git a/README.md b/README.md index 170ddb05..f8ea62b5 100644 --- a/README.md +++ b/README.md @@ -33,18 +33,17 @@ For installation without brew, see the [Install](INSTALL.md#macos) page. ### Windows -Using [scoop](https://scoop.sh/) is recommended: +Using winget is recommended: ```sh -$ scoop bucket add doppler https://github.com/DopplerHQ/scoop-doppler.git -$ scoop install doppler +$ winget install doppler $ doppler --version ``` To update: ```sh -$ scoop update doppler +$ winget upgrade doppler ``` For additional options, see the [Install](INSTALL.md#windows) page. diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index faf0cb4c..a8844455 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -19,7 +19,6 @@ import ( "fmt" "os" "sync" - "time" "github.com/DopplerHQ/cli/pkg/configuration" "github.com/DopplerHQ/cli/pkg/controllers" @@ -66,7 +65,9 @@ var rootCmd = &cobra.Command{ // --plain doesn't normally affect logging output, but due to legacy reasons it does here // also don't want to display updates if user doesn't want to be prompted (--no-prompt/--no-interactive) if isTTY && utils.CanLogInfo() && !plain && canPrompt { - checkVersion(cmd.CommandPath()) + if available, latestVersion := controllers.CheckUpdate(cmd.CommandPath()); available { + controllers.PromptToUpdate(latestVersion) + } } }, Run: func(cmd *cobra.Command, args []string) { @@ -77,63 +78,6 @@ var rootCmd = &cobra.Command{ }, } -func checkVersion(command string) { - // disable version checking on commands commonly used in production workflows - // also disable when explicitly calling 'update' command to avoid checking twice - disabledCommands := []string{"run", "secrets download", "update"} - for _, disabledCommand := range disabledCommands { - if command == fmt.Sprintf("doppler %s", disabledCommand) { - utils.LogDebug("Skipping CLI upgrade check due to disallowed command") - return - } - } - - if !version.PerformVersionCheck || version.IsDevelopment() { - return - } - - prevVersionCheck := configuration.VersionCheck() - // don't check more often than every 24 hours - if !time.Now().After(prevVersionCheck.CheckedAt.Add(24 * time.Hour)) { - return - } - - controllers.CaptureEvent("VersionCheck", nil) - - available, versionCheck, err := controllers.NewVersionAvailable(prevVersionCheck) - if err != nil { - // retry on next run - return - } - - if !available { - utils.LogDebug("No CLI updates available") - // re-use existing version - versionCheck.LatestVersion = prevVersionCheck.LatestVersion - } else if utils.IsWindows() { - utils.Log(fmt.Sprintf("Update: Doppler CLI %s is available\n\nYou can update via 'scoop update doppler'\n", versionCheck.LatestVersion)) - } else { - controllers.CaptureEvent("UpgradeAvailable", nil) - - utils.Print(color.Green.Sprintf("An update is available.")) - - changes, apiError := controllers.CLIChangeLog() - if apiError.IsNil() { - printer.ChangeLog(changes, 1, false) - utils.Print("") - } - - prompt := fmt.Sprintf("Install Doppler CLI %s", versionCheck.LatestVersion) - if utils.ConfirmationPrompt(prompt, true) { - controllers.CaptureEvent("UpgradeFromPrompt", nil) - - installCLIUpdate() - } - } - - configuration.SetVersionCheck(versionCheck) -} - // persistentValidArgsFunction Cobra parses flags after executing ValidArgsFunction, so we must manually initialize flags func persistentValidArgsFunction(cmd *cobra.Command) { // more info https://github.com/spf13/cobra/issues/1291 diff --git a/pkg/cmd/update.go b/pkg/cmd/update.go index 5355860d..d213a2aa 100644 --- a/pkg/cmd/update.go +++ b/pkg/cmd/update.go @@ -16,11 +16,8 @@ limitations under the License. package cmd import ( - "fmt" - "github.com/DopplerHQ/cli/pkg/controllers" "github.com/DopplerHQ/cli/pkg/models" - "github.com/DopplerHQ/cli/pkg/printer" "github.com/DopplerHQ/cli/pkg/utils" "github.com/spf13/cobra" ) @@ -30,52 +27,25 @@ var updateCmd = &cobra.Command{ Short: "Update the Doppler CLI", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { - if !utils.CanUpdate() { - utils.HandleError(fmt.Errorf("this command is not yet implemented for your operating system")) - } - force := utils.GetBoolFlag(cmd, "force") - available, _, err := controllers.NewVersionAvailable(models.VersionCheck{}) + available, version, err := controllers.NewVersionAvailable(models.VersionCheck{}) if err != nil { utils.HandleError(err, "Unable to check for CLI updates") } if !available { if force { - utils.Log(fmt.Sprintf("Already running the latest version but proceeding anyway due to --force flag")) + utils.Log("Already running the latest version but proceeding anyway due to --force flag") } else { - utils.Print(fmt.Sprintf("You are already running the latest version")) + utils.Print("You are already running the latest version") return } } - installCLIUpdate() + controllers.InstallUpdate(version.LatestVersion) }, } -func installCLIUpdate() { - utils.Print("Updating...") - wasUpdated, installedVersion, controllerErr := controllers.RunInstallScript() - if !controllerErr.IsNil() { - utils.HandleError(controllerErr.Unwrap(), controllerErr.Message) - } - - if wasUpdated { - utils.Print(fmt.Sprintf("Installed CLI %s", installedVersion)) - - if changes, apiError := controllers.CLIChangeLog(); apiError.IsNil() { - utils.Print("\nWhat's new:") - printer.ChangeLog(changes, 1, false) - utils.Print("\nTip: run 'doppler changelog' to see all latest changes") - } - - utils.Print("") - } else { - utils.Print(fmt.Sprintf("You are already running the latest version")) - } - -} - func init() { updateCmd.Flags().BoolP("force", "f", false, "install the latest CLI regardless of whether there's an update available") rootCmd.AddCommand(updateCmd) diff --git a/pkg/controllers/update.go b/pkg/controllers/update.go index 339b5799..8ca3fafc 100644 --- a/pkg/controllers/update.go +++ b/pkg/controllers/update.go @@ -16,20 +16,27 @@ limitations under the License. package controllers import ( + "bytes" "errors" "fmt" + "io" "os" "os/exec" "regexp" "strings" "time" + "github.com/DopplerHQ/cli/pkg/configuration" "github.com/DopplerHQ/cli/pkg/http" "github.com/DopplerHQ/cli/pkg/models" + "github.com/DopplerHQ/cli/pkg/printer" "github.com/DopplerHQ/cli/pkg/utils" "github.com/DopplerHQ/cli/pkg/version" + "gopkg.in/gookit/color.v1" ) +const wingetPackageId = "Doppler.doppler" + // Error controller errors type Error struct { Err error @@ -42,6 +49,81 @@ func (e *Error) Unwrap() error { return e.Err } // IsNil whether the error is nil func (e *Error) IsNil() bool { return e.Err == nil && e.Message == "" } +// CheckUpdate checks whether an update is available +func CheckUpdate(command string) (bool, models.VersionCheck) { + // disable version checking on commands commonly used in production workflows + // also disable when explicitly calling 'update' command to avoid checking twice + disabledCommands := []string{"run", "secrets download", "update"} + for _, disabledCommand := range disabledCommands { + if command == fmt.Sprintf("doppler %s", disabledCommand) { + utils.LogDebug("Skipping CLI upgrade check due to disallowed command") + return false, models.VersionCheck{} + } + } + + if !version.PerformVersionCheck || version.IsDevelopment() { + return false, models.VersionCheck{} + } + + prevVersionCheck := configuration.VersionCheck() + // don't check more often than every 24 hours + if !time.Now().After(prevVersionCheck.CheckedAt.Add(24 * time.Hour)) { + return false, models.VersionCheck{} + } + + CaptureEvent("VersionCheck", nil) + + available, versionCheck, err := NewVersionAvailable(prevVersionCheck) + if err != nil { + return false, models.VersionCheck{} + } + + if !available { + utils.LogDebug("No CLI updates available") + prevVersionCheck.CheckedAt = time.Now() + configuration.SetVersionCheck(prevVersionCheck) + return false, models.VersionCheck{} + } + + if utils.IsWindows() && !utils.IsMINGW64() { + if !InstalledViaWinget() { + utils.Log(fmt.Sprintf("Update: Doppler CLI %s is available\n\nYou can update via 'scoop update doppler'\nWe recommend installing the Doppler CLI via winget for easier updates", versionCheck.LatestVersion)) + configuration.SetVersionCheck(versionCheck) + return false, models.VersionCheck{} + } + + if !IsUpdateAvailableViaWinget(versionCheck.LatestVersion) { + CaptureEvent("UpgradeNotAvailableViaWinget", map[string]interface{}{"version": versionCheck.LatestVersion}) + utils.LogDebug(fmt.Sprintf("Doppler CLI version %s is not yet available via winget", versionCheck.LatestVersion)) + // reuse old version so we prompt the user again + prevVersionCheck.CheckedAt = time.Now() + configuration.SetVersionCheck(prevVersionCheck) + return false, models.VersionCheck{} + } + } + + CaptureEvent("UpgradeAvailable", nil) + return true, versionCheck +} + +func PromptToUpdate(latestVersion models.VersionCheck) { + utils.Print(color.Green.Sprintf("An update is available.")) + + changes, apiError := CLIChangeLog() + if apiError.IsNil() { + printer.ChangeLog(changes, 1, false) + utils.Print("") + } + + prompt := fmt.Sprintf("Install Doppler CLI %s", latestVersion.LatestVersion) + if utils.ConfirmationPrompt(prompt, true) { + CaptureEvent("UpgradeFromPrompt", nil) + InstallUpdate(latestVersion.LatestVersion) + } else { + configuration.SetVersionCheck(latestVersion) + } +} + // RunInstallScript downloads and executes the CLI install scriptm, returning true if an update was installed func RunInstallScript() (bool, string, Error) { startTime := time.Now() @@ -50,42 +132,54 @@ func RunInstallScript() (bool, string, Error) { if !apiErr.IsNil() { return false, "", Error{Err: apiErr.Unwrap(), Message: apiErr.Message} } - fetchScriptDuration := time.Now().Sub(startTime).Milliseconds() + fetchScriptDuration := time.Since(startTime).Milliseconds() CaptureEvent("InstallScriptDownloaded", map[string]interface{}{"durationMs": fetchScriptDuration}) // write script to temp file tmpFile, err := utils.WriteTempFile("install.sh", script, 0555) - // clean up temp file once we're done with it - defer os.Remove(tmpFile) + if tmpFile != "" { + // clean up temp file once we're done with it + defer os.Remove(tmpFile) + } + if err != nil { + return false, "", Error{Err: err, Message: "Unable to save install script"} + } // execute script utils.LogDebug("Executing install script") command := []string{tmpFile, "--debug"} + utils.LogDebug(fmt.Sprintf("Executing \"%s\"", command)) startTime = time.Now() - var out []byte + + var out bytes.Buffer + var cmd *exec.Cmd if utils.IsWindows() { - // executing in sh on Windows avoids errors like this: - // Doppler Error: fork/exec C:\...\.install.sh.1063970983: %1 is not a valid Win32 application. - out, err = exec.Command("sh", command...).CombinedOutput() // #nosec G204 + // must execute in sh on MINGW64 Windows to avoid "command not found" error + c := []string{"sh"} + c = append(c, command...) + cmd, err = utils.RunCommand(c, os.Environ(), nil, &out, &out, true) } else { - out, err = exec.Command(command[0], command[1:]...).CombinedOutput() // #nosec G204 + cmd, err = utils.RunCommand(command, os.Environ(), nil, &out, &out, true) } + waitExitCode, waitErr := utils.WaitCommand(cmd) - executeDuration := time.Now().Sub(startTime).Milliseconds() + executeDuration := time.Since(startTime).Milliseconds() + strOut := out.String() - strOut := string(out) // log output before checking error - utils.LogDebug(fmt.Sprintf("Executing \"%s\"", strings.Join(command, " "))) if utils.Debug { // use Fprintln rather than LogDebug so that we don't display a duplicate "DEBUG" prefix fmt.Fprintln(os.Stderr, strOut) // nosemgrep: semgrep_configs.prohibit-print } - if err != nil { + if err != nil || waitErr != nil { + if waitErr != nil { + err = waitErr + } exitCode := 1 - if exitError, ok := err.(*exec.ExitError); ok { - exitCode = exitError.ExitCode() + if waitExitCode != 0 { + exitCode = waitExitCode } CaptureEvent("InstallScriptFailed", map[string]interface{}{"durationMs": executeDuration, "exitCode": exitCode}) @@ -149,3 +243,128 @@ func CLIChangeLog() (map[string]models.ChangeLog, http.Error) { changes := models.ParseChangeLog(response) return changes, http.Error{} } + +func InstallUpdate(version string) { + utils.Print("Updating...") + + var wasUpdated bool + var installedVersion string + var controllerErr Error + if utils.IsWindows() && !utils.IsMINGW64() { + if InstalledViaWinget() { + wasUpdated, installedVersion, controllerErr = UpdateViaWinget(version) + } else { + utils.HandleError(fmt.Errorf("updates are not supported when installed via scoop. Please install the Doppler CLI via winget or update manually via `scoop update doppler`")) + } + } else { + wasUpdated, installedVersion, controllerErr = RunInstallScript() + } + if !controllerErr.IsNil() { + utils.HandleError(controllerErr.Unwrap(), controllerErr.Message) + } + + if wasUpdated { + utils.Print(fmt.Sprintf("Installed CLI %s", installedVersion)) + + if changes, apiError := CLIChangeLog(); apiError.IsNil() { + utils.Print("\nWhat's new:") + printer.ChangeLog(changes, 1, false) + utils.Print("\nTip: run 'doppler changelog' to see all latest changes") + } + + utils.Print("") + } else { + utils.Print("You are already running the latest version") + } + + versionCheck := models.VersionCheck{LatestVersion: installedVersion, CheckedAt: time.Now()} + configuration.SetVersionCheck(versionCheck) +} + +func InstalledViaWinget() bool { + utils.LogDebug("Checking if CLI is installed via winget") + command := fmt.Sprintf("winget list --id %s -n 1 --exact --disable-interactivity", wingetPackageId) + utils.LogDebug(fmt.Sprintf("Executing \"%s\"", command)) + + var out io.Writer = nil + if utils.CanLogDebug() { + out = os.Stderr + } + cmd, err := utils.RunCommandString(command, os.Environ(), nil, out, out, true) + if err != nil { + utils.LogDebugError(err) + return false + } + _, err = utils.WaitCommand(cmd) + if err != nil { + utils.LogDebugError(err) + } + return err == nil +} + +func IsUpdateAvailableViaWinget(updateVersion string) bool { + utils.LogDebug("Checking if CLI update is available via winget") + command := fmt.Sprintf("winget list --id %s -n 1 --exact --disable-interactivity", wingetPackageId) + utils.LogDebug(fmt.Sprintf("Executing \"%s\"", command)) + + var out bytes.Buffer + cmd, err := utils.RunCommandString(command, os.Environ(), nil, &out, &out, true) + if err != nil { + utils.LogDebugError(err) + return false + } + + _, err = utils.WaitCommand(cmd) + if err != nil { + utils.LogDebugError(err) + // not installed via winget + return false + } + strOut := out.String() + utils.LogDebug(strOut) + + // Ex: `Doppler.doppler 3.63.1 3.64.0 winget` + re := regexp.MustCompile(fmt.Sprintf(`%s\s+%s\s+%s\s+winget`, wingetPackageId, strings.TrimPrefix(version.ProgramVersion, "v"), strings.TrimPrefix(updateVersion, "v"))) + + matches := re.FindStringSubmatch(strOut) + return len(matches) > 0 +} + +func UpdateViaWinget(version string) (bool, string, Error) { + command := fmt.Sprintf("winget upgrade --id %s --exact --disable-interactivity --version %s", wingetPackageId, strings.TrimPrefix(version, "v")) + utils.LogDebug(fmt.Sprintf("Executing \"%s\"", command)) + + startTime := time.Now() + + var out bytes.Buffer + cmd, err := utils.RunCommandString(command, os.Environ(), nil, &out, &out, true) + if err != nil { + utils.LogDebugError(err) + CaptureEvent("WingetUpgradeFailed", map[string]interface{}{"durationMs": 0}) + return false, "", Error{Message: "Unable to execute winget"} + } + + exitCode, err := utils.WaitCommand(cmd) + + strOut := out.String() + utils.LogDebug(strOut) + + executeDuration := time.Since(startTime).Milliseconds() + + if err != nil || exitCode != 0 { + var e Error + if strings.Contains(strOut, "No installed package found matching input criteria.") { + e = Error{Message: "The Doppler CLI is not installed via winget"} + } else if strings.Contains(strOut, "No applicable upgrade found.") || strings.Contains(strOut, "No available upgrade found.") { + e = Error{Message: "You are already running the latest version available via winget"} + } else { + e = Error{Err: err, Message: "Unable to upgrade via winget"} + } + + CaptureEvent("WingetUpgradeFailed", map[string]interface{}{"durationMs": executeDuration}) + return false, "", e + } + + CaptureEvent("WingetUpgradeCompleted", map[string]interface{}{"durationMs": executeDuration}) + return true, version, Error{} +} diff --git a/pkg/utils/io.go b/pkg/utils/io.go index fed4c1af..f13e87bc 100644 --- a/pkg/utils/io.go +++ b/pkg/utils/io.go @@ -63,18 +63,18 @@ func WriteTempFile(name string, data []byte, perm os.FileMode) (string, error) { return "", err } - LogDebug(fmt.Sprintf("Writing to temp file %s", tmpFile.Name())) + tmpFileName := tmpFile.Name() + LogDebug(fmt.Sprintf("Writing to temp file %s", tmpFileName)) if _, err := tmpFile.Write(data); err != nil { - return "", err + return tmpFileName, err } - tmpFileName := tmpFile.Name() if err := tmpFile.Close(); err != nil { - return "", err + return tmpFileName, err } if err := os.Chmod(tmpFileName, perm); err != nil { - return "", err + return tmpFileName, err } return tmpFileName, nil diff --git a/pkg/utils/log.go b/pkg/utils/log.go index 35ab47fe..d00a4947 100644 --- a/pkg/utils/log.go +++ b/pkg/utils/log.go @@ -54,7 +54,7 @@ func CanLogInfo() bool { return Debug || !silent } -// LogDebug prints a debug message to stdout +// LogDebug prints a debug message to stderr func LogDebug(s string) { if CanLogDebug() { // log debug messages to stderr @@ -69,7 +69,7 @@ func LogDebugError(e error) { } } -// CanLogDebug messages to stdout +// CanLogDebug messages to stderr func CanLogDebug() bool { return Debug } @@ -92,7 +92,9 @@ func ErrExit(e error, exitCode int, messages ...string) { fmt.Fprintln(os.Stderr, messages[0]) } - printError(e) + if e != nil { + printError(e) + } if len(messages) > 0 { for _, message := range messages[1:] { diff --git a/pkg/utils/util.go b/pkg/utils/util.go index 8c9cabee..a535eb5b 100644 --- a/pkg/utils/util.go +++ b/pkg/utils/util.go @@ -18,6 +18,7 @@ package utils import ( "errors" "fmt" + "io" "os" "os/exec" "os/signal" @@ -105,8 +106,8 @@ func Cwd() string { } // RunCommand runs the specified command -func RunCommand(command []string, env []string, inFile *os.File, outFile *os.File, errFile *os.File, forwardSignals bool) (*exec.Cmd, error) { - cmd := exec.Command(command[0], command[1:]...) // #nosec G204 +func RunCommand(command []string, env []string, inFile io.Reader, outFile io.Writer, errFile io.Writer, forwardSignals bool) (*exec.Cmd, error) { + cmd := exec.Command(command[0], command[1:]...) // #nosec G204 nosemgrep: semgrep_configs.prohibit-exec-command cmd.Env = env cmd.Stdin = inFile cmd.Stdout = outFile @@ -117,7 +118,7 @@ func RunCommand(command []string, env []string, inFile *os.File, outFile *os.Fil } // RunCommandString runs the specified command string -func RunCommandString(command string, env []string, inFile *os.File, outFile *os.File, errFile *os.File, forwardSignals bool) (*exec.Cmd, error) { +func RunCommandString(command string, env []string, inFile io.Reader, outFile io.Writer, errFile io.Writer, forwardSignals bool) (*exec.Cmd, error) { shell := [2]string{"sh", "-c"} if IsWindows() { shell = [2]string{"cmd", "/C"} @@ -132,7 +133,7 @@ func RunCommandString(command string, env []string, inFile *os.File, outFile *os } } } - cmd := exec.Command(shell[0], shell[1], command) // #nosec G204 + cmd := exec.Command(shell[0], shell[1], command) // #nosec G204 nosemgrep: semgrep_configs.prohibit-exec-command cmd.Env = env cmd.Stdin = inFile cmd.Stdout = outFile @@ -388,17 +389,6 @@ func HostArch() string { return arch } -// CanUpdate whether the host os supports updating via CLI -func CanUpdate() bool { - if IsMINGW64() { - return true - } else if IsWindows() { - return false - } else { - return true - } -} - // IsWindows whether the host os is Windows func IsWindows() bool { return runtime.GOOS == "windows" @@ -407,7 +397,7 @@ func IsWindows() bool { // IsMINGW64 whether the host os is running in a MINGW64-based // environment like Git Bash, Cygwin, etc. func IsMINGW64() bool { - return os.Getenv("MSYSTEM") == "MINGW64" + return IsWindows() && os.Getenv("MSYSTEM") == "MINGW64" } // IsMacOS whether the host os is macOS diff --git a/semgrep_configs/exec.yaml b/semgrep_configs/exec.yaml new file mode 100644 index 00000000..243e2e4a --- /dev/null +++ b/semgrep_configs/exec.yaml @@ -0,0 +1,9 @@ +rules: + - id: prohibit-exec-command + languages: + - go + message: > + Use utils.RunCommand or utils.RunCommandString to ensure exec is os agnostic. + pattern-either: + - pattern: exec.Command + severity: ERROR diff --git a/tests/e2e.sh b/tests/e2e.sh index b7586874..6032c2e9 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -23,6 +23,7 @@ export DOPPLER_CONFIG="e2e" "$DIR/e2e/setup.sh" "$DIR/e2e/me.sh" "$DIR/e2e/global-flags.sh" +"$DIR/e2e/update.sh" echo -e "\nAll tests completed successfully!" exit 0 diff --git a/tests/e2e/update.sh b/tests/e2e/update.sh new file mode 100755 index 00000000..ef04a3a3 --- /dev/null +++ b/tests/e2e/update.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +set -euo pipefail + +TEST_NAME="update" + +cleanup() { + exit_code=$? + if [ "$exit_code" -ne 0 ]; then + echo "ERROR: '$TEST_NAME' tests failed during execution" + afterAll || echo "ERROR: Cleanup failed" + fi + + exit "$exit_code" +} +trap cleanup EXIT +trap cleanup INT + +beforeAll() { + echo "INFO: Executing '$TEST_NAME' tests" +} + +beforeEach() { + rm -rf ./temp-config +} + +afterAll() { + echo "INFO: Completed '$TEST_NAME' tests" + beforeEach +} + +error() { + message=$1 + echo "$message" + exit 1 +} + +beforeAll + +beforeEach + +### update fails w/o sudo +output="$("$DOPPLER_BINARY" update --force 2>&1 || true)"; +[ "$(echo "$output" | tail -1)" == "Doppler Error: exit status 2" ] || error "ERROR: expected update to fail without sudo" + +beforeEach + +### gnupg perms issue +# make gnupg directory inaccessible +sudo chown root ~/.gnupg; +output="$("$DOPPLER_BINARY" update --force 2>&1 || true)"; +[ "$(echo "$output" | tail -1)" == "Doppler Error: exit status 4" ] || error "ERROR: expected update to fail without access to gnupg" +# restore gnupg directory perms +sudo chown "$(id -un)" ~/.gnupg; + +beforeEach + +### successful update +sudo "$DOPPLER_BINARY" update --force >/dev/null 2>&1 || error "ERROR: unable to update CLI" + + +afterAll