diff --git a/index.js b/index.js index 00031975..ac6dc51b 100755 --- a/index.js +++ b/index.js @@ -593,8 +593,10 @@ async function main (testOptions) { * Log the common setup start message to the console */ async function logSetupStart () { - console.clear() - console.log(`${chalk.bold('Welcome to the')} ${chalk.cyan('FlowFuse Device Agent')} ${chalk.gray(`v${pkg.version}`)}`) + if (!installerMode) { + console.clear() + console.log(`${chalk.bold('Welcome to the')} ${chalk.cyan('FlowFuse Device Agent')} ${chalk.gray(`v${pkg.version}`)}`) + } } /** diff --git a/installer/go/cmd/install.go b/installer/go/cmd/install.go index 10eb688f..63f99166 100644 --- a/installer/go/cmd/install.go +++ b/installer/go/cmd/install.go @@ -236,10 +236,10 @@ func Uninstall(customWorkDir string) error { s := cfg.ServiceName logger.Debug("Loaded service name from config: %s", s) switch s { - case "": - serviceName = "flowfuse-device-agent" - default: - serviceName = s + case "": + serviceName = "flowfuse-device-agent" + default: + serviceName = s } } @@ -377,10 +377,10 @@ func Update(agentVersion, nodeVersion, customWorkDir string, updateAgent, update s := cfg.ServiceName logger.Debug("Loaded service name from config: %s", s) switch s { - case "": - serviceName = "flowfuse-device-agent" - default: - serviceName = s + case "": + serviceName = "flowfuse-device-agent" + default: + serviceName = s } } diff --git a/installer/go/main.go b/installer/go/main.go index 36fa4c05..a6afdefc 100644 --- a/installer/go/main.go +++ b/installer/go/main.go @@ -3,10 +3,13 @@ package main import ( "fmt" "os" + "os/signal" "path/filepath" + "syscall" "github.com/flowfuse/device-agent-installer/cmd" "github.com/flowfuse/device-agent-installer/pkg/logger" + "github.com/flowfuse/device-agent-installer/pkg/style" "github.com/flowfuse/device-agent-installer/pkg/utils" "github.com/spf13/pflag" ) @@ -33,7 +36,7 @@ func init() { pflag.StringVarP(&nodeVersion, "nodejs-version", "n", "22.23.0", "Node.js version to install (minimum)") pflag.StringVarP(&agentVersion, "agent-version", "a", "latest", "Device agent version to install/update to") pflag.StringVarP(&serviceUsername, "service-user", "s", "flowfuse", "Username for the service account") - pflag.StringVarP(&flowfuseURL, "url", "u", "https://app.flowfuse.com", "FlowFuse URL") + pflag.StringVarP(&flowfuseURL, "url", "u", "", "FlowFuse URL") pflag.StringVarP(&flowfuseOneTimeCode, "otc", "o", "", "FlowFuse one time code for authentication (optional for interactive installation)") pflag.StringVarP(&installDir, "dir", "d", "", "Custom installation directory (default: /opt/flowfuse-device on Unix, c:\\opt\\flowfuse-device on Windows)") pflag.IntVarP(&port, "port", "p", 1880, "TCP port for the device agent (1-65535)") @@ -95,43 +98,40 @@ func main() { defer logger.Close() } + // Handle Ctrl-C (and SIGTERM) gracefully: if the user interrupts while a + // prompt is on screen, restore the terminal so no dangling cursor-save state + // is left behind (which would otherwise break cursor handling until reset). + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigCh + style.CancelPrompt() + fmt.Fprintln(os.Stdout, "\nCancelled.") + logger.Close() + os.Exit(130) + }() + // Log startup information logger.Debug("Command line arguments: node=%s, agent=%s, user=%s, url=%s, debug=%v, customInstallDir=%s, port=%d, caCert=%s", nodeVersion, agentVersion, serviceUsername, flowfuseURL, debugMode, installDir, port, caCertPath) operatingSystem, architecture := utils.GetOSDetails() logger.Debug("Detected system: %s, detected architecture: %s", operatingSystem, architecture) - logger.Info("****************************************************************") - logger.Info("* FlowFuse Device Agent Installer *") - logger.Info("* *") - logger.Info("* This installer will set up the FlowFuse Device Agent on your *") - logger.Info("* system and configure it to run as a system service. *") - logger.Info("* *") - logger.Info("****************************************************************") + logger.Info("%s %s", style.Bold("Welcome to the"), style.Cyan("FlowFuse Device Agent Installer")) if debugMode { logger.Info("Debug mode enabled. Logs will be written to: %s", logger.GetLogFilePath()) logger.Debug("FlowFuse Device Agent Installer version: %s", instVersion) } - if flowfuseOneTimeCode == "" && !uninstall && !updateNode && !updateAgent { - fmt.Println("One time code has not been provided. The Device Agent automatic configuration is not possible.") - response := utils.PromptYesNo("Do you want to continue with the installation?", false) - if !response { - fmt.Println("Installation aborted by user.") - os.Exit(1) - } else { - fmt.Println("Continuing with installation in interactive mode...") - } - } - if uninstall { err = cmd.Uninstall(installDir) } else if updateNode || updateAgent { - logger.Info("Updating FlowFuse Device Agent...") err = cmd.Update(agentVersion, nodeVersion, installDir, updateAgent, updateNode) } else { - logger.Info("Installing FlowFuse Device Agent...") + logger.Info("") + logger.Info("Let's get your connected to FlowFuse.") + logger.Info("") err = cmd.Install(nodeVersion, agentVersion, flowfuseURL, flowfuseOneTimeCode, installDir, false, port, caCertPath) } diff --git a/installer/go/pkg/logger/logger.go b/installer/go/pkg/logger/logger.go index 177c4310..aa851361 100644 --- a/installer/go/pkg/logger/logger.go +++ b/installer/go/pkg/logger/logger.go @@ -14,10 +14,21 @@ import ( "log" "os" "path/filepath" + "regexp" "sync" "time" ) +// ansiEscapeRE matches ANSI SGR escape sequences (e.g. those produced by the +// style package). They are stripped from file output so the log file stays +// plain text while the console keeps its styling. +var ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// stripANSI removes ANSI escape sequences from s. +func stripANSI(s string) string { + return ansiEscapeRE.ReplaceAllString(s, "") +} + var ( // Global debug flag @@ -122,12 +133,14 @@ func Debug(format string, v ...interface{}) { mutex.Lock() defer mutex.Unlock() + message := fmt.Sprintf(format, v...) + if fileDebugLogger != nil { - fileDebugLogger.Output(2, fmt.Sprintf(format, v...)) + fileDebugLogger.Output(2, stripANSI(message)) } if consoleDebugLogger != nil { - consoleDebugLogger.Output(2, fmt.Sprintf(format, v...)) + consoleDebugLogger.Output(2, message) } } @@ -146,7 +159,7 @@ func Info(format string, v ...interface{}) { message := fmt.Sprintf(format, v...) if fileInfoLogger != nil { - fileInfoLogger.Output(2, message) + fileInfoLogger.Output(2, stripANSI(message)) } if consoleInfoLogger != nil { @@ -172,7 +185,7 @@ func Error(format string, v ...interface{}) { message := fmt.Sprintf(format, v...) if fileErrorLogger != nil { - fileErrorLogger.Output(2, message) + fileErrorLogger.Output(2, stripANSI(message)) } if consoleErrorLogger != nil { diff --git a/installer/go/pkg/nodejs/deviceagent.go b/installer/go/pkg/nodejs/deviceagent.go index 6505bd9c..7e9df82f 100644 --- a/installer/go/pkg/nodejs/deviceagent.go +++ b/installer/go/pkg/nodejs/deviceagent.go @@ -312,113 +312,67 @@ func ConfigureDeviceAgent(url, token, baseDir string, port int) (string, bool, e deviceAgentPath = filepath.Join(nodeBinDirPath, "flowfuse-device-agent.cmd") } + // Create configure command. + // Build the arguments shared across platforms first, adding the optional + // -o/-u flags only when a value is provided, then layer the OS-specific + // flags on top inside each case. + agentArgs := []string{"--otc-no-start", "--installer-mode"} if token != "" { - // Create configure command - var configureCmd *exec.Cmd - switch runtime.GOOS { - case "linux", "darwin": - configureCmd = exec.Command("sudo", preserveEnv, deviceAgentPath, "-o", token, "-u", url, "--dir", baseDir, "--port", fmt.Sprintf("%d", port), "--otc-no-start", "--installer-mode") - env := os.Environ() - configureCmd.Dir = baseDir - configureCmd.Env = append(env, newPath) - case "windows": - configureCmd = exec.Command("powershell", "-Command", "&", fmt.Sprintf(`'%s'`, deviceAgentPath), "-o", token, "-u", url, "--dir", fmt.Sprintf(`'%s'`, baseDir), "--port", fmt.Sprintf(`'%d'`, port), "--otc-no-start", "--installer-mode", "-v") - env := os.Environ() - configureCmd.Dir = baseDir - configureCmd.Env = append(env, newPath) - default: - return "", false, fmt.Errorf("unsupported operating system: %s", runtime.GOOS) - } + agentArgs = append(agentArgs, "-o", token) + } + if url != "" { + agentArgs = append(agentArgs, "-u", url) + } - logger.Debug("Configure command: %s", configureCmd.String()) + var configureCmd *exec.Cmd + switch runtime.GOOS { + case "linux", "darwin": + args := append([]string{preserveEnv, deviceAgentPath}, agentArgs...) + args = append(args, "--dir", baseDir, "--port", fmt.Sprintf("%d", port)) + configureCmd = exec.Command("sudo", args...) + env := os.Environ() + configureCmd.Dir = baseDir + configureCmd.Env = append(env, newPath) + case "windows": + args := append([]string{"-Command", "&", fmt.Sprintf(`'%s'`, deviceAgentPath)}, agentArgs...) + args = append(args, "--dir", fmt.Sprintf(`'%s'`, baseDir), "--port", fmt.Sprintf(`'%d'`, port)) + configureCmd = exec.Command("powershell", args...) + env := os.Environ() + configureCmd.Dir = baseDir + configureCmd.Env = append(env, newPath) + default: + return "", false, fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } - // Connect stdin, stdout, and stderr for interactive processes - configureCmd.Stdin = os.Stdin - configureCmd.Stdout = os.Stdout - configureCmd.Stderr = os.Stderr + logger.Debug("Configure command: %s", configureCmd.String()) - logger.Debug("Starting device agent configuration") + // Connect stdin, stdout, and stderr for interactive processes + configureCmd.Stdin = os.Stdin + configureCmd.Stdout = os.Stdout + configureCmd.Stderr = os.Stderr - // Run the command interactively - if err := configureCmd.Run(); err != nil { - return "", false, fmt.Errorf("failed to configure the device agent: %w", err) - } + logger.Debug("Starting device agent configuration") - var chownCmd *exec.Cmd - switch runtime.GOOS { - case "linux": - chownCmd = exec.Command("sudo", "chown", "-R", serviceUser+":"+serviceUser, baseDir) - case "darwin": - chownCmd = exec.Command("sudo", "chown", "-R", serviceUser, baseDir) - case "windows": - logger.Info("Configuration completed successfully!") - return "otc", true, nil - } - // Set permissions for the working directory - if output, err := chownCmd.CombinedOutput(); err != nil { - return "", false, fmt.Errorf("failed to set directory ownership: %w\nOutput: %s", err, output) - } + // Run the command interactively + if err := configureCmd.Run(); err != nil { + return "", false, fmt.Errorf("failed to configure the device agent: %w", err) + } + var chownCmd *exec.Cmd + switch runtime.GOOS { + case "linux": + chownCmd = exec.Command("sudo", "chown", "-R", serviceUser+":"+serviceUser, baseDir) + case "darwin": + chownCmd = exec.Command("sudo", "chown", "-R", serviceUser, baseDir) + case "windows": logger.Info("Configuration completed successfully!") return "otc", true, nil - } else { - - logger.Info("No OTC (One-Time Code) provided. Automatic configuration is not possible.") - options := []string{ - "Provide a device configuration file now", - "Install the device agent only (you'll need to configure it manually later)", - } - choice, err := utils.PromptOption("You can either:", options, 0) - if err != nil { - logger.Error("Failed to get user selection: %v", err) - return "", false, fmt.Errorf("failed to get user selection: %w", err) - } - configProvided := choice == 0 - - if configProvided { - // Manual configuration mode - logger.Info("Please paste your device configuration below.") - logger.Info("The configuration should be in YAML format with all required fields.") - logger.Info("Enter an empty line when done:") - - configContent, err := utils.PromptMultilineInput() - if err != nil { - logger.Error("Failed to read configuration input: %v", err) - return "", false, fmt.Errorf("failed to read configuration input: %w", err) - } - - // Validate configuration - if err := utils.ValidateDeviceConfiguration(configContent); err != nil { - logger.Error("Invalid device configuration: %v", err) - return "", false, fmt.Errorf("invalid device configuration: %w", err) - } - - // Save configuration to device.yml - if err := utils.SaveDeviceConfiguration(configContent, deviceConfigPath); err != nil { - logger.Error("Failed to save device configuration: %v", err) - return "", false, fmt.Errorf("failed to save device configuration: %w", err) - } - - var chownCmd *exec.Cmd - switch runtime.GOOS { - case "linux": - chownCmd = exec.Command("sudo", "chown", "-R", serviceUser+":"+serviceUser, baseDir) - case "darwin": - chownCmd = exec.Command("sudo", "chown", "-R", serviceUser, baseDir) - case "windows": - logger.Info("Configuration completed successfully!") - return "manual", true, nil - } - // Set permissions for the working directory - if output, err := chownCmd.CombinedOutput(); err != nil { - return "", false, fmt.Errorf("failed to set directory ownership: %w\nOutput: %s", err, output) - } - - logger.Info("Configuration completed successfully!") - return "manual", true, nil - } - - logger.Info("Configuration completed successfully!") - return "install-only", false, nil } + // Set permissions for the working directory + if output, err := chownCmd.CombinedOutput(); err != nil { + return "", false, fmt.Errorf("failed to set directory ownership: %w\nOutput: %s", err, output) + } + + logger.Info("Configuration completed successfully!") + return "otc", true, nil } diff --git a/installer/go/pkg/service/linux.go b/installer/go/pkg/service/linux.go index 0ee313a3..790c4338 100644 --- a/installer/go/pkg/service/linux.go +++ b/installer/go/pkg/service/linux.go @@ -24,15 +24,28 @@ type ServiceConfig struct { NodeExtraCACerts string // Optional custom CA bundle path (NODE_EXTRA_CA_CERTS) } -// IsSystemd returns true if the system uses systemd, false otherwise -// This is determined by checking if the "systemctl" command is available +// IsSystemd returns true if the system was booted with systemd as its init +// system, false otherwise. +// +// This requires both that systemd is actually running as PID 1 (detected via the +// /run/systemd/system directory, the same check libsystemd's sd_booted() uses) +// and that the "systemctl" command is available. Checking only for the systemctl +// binary is insufficient: distributions such as Ubuntu ship the systemd package +// (and therefore systemctl) even in environments where systemd is not the running +// init - e.g. containers - causing systemctl calls to fail with "System has not +// been booted with systemd as init system (PID 1)". // // Returns: -// - true if systemd is found, false otherwise +// - true if systemd is the running init system, false otherwise func IsSystemd() bool { logger.LogFunctionEntry("IsSystemd", nil) + defer logger.LogFunctionExit("IsSystemd", nil, nil) + + // systemd is only the active init system if it has populated /run/systemd/system. + if _, err := os.Stat("/run/systemd/system"); err != nil { + return false + } _, err := exec.LookPath("systemctl") - logger.LogFunctionExit("IsSystemd", nil, nil) return err == nil } diff --git a/installer/go/pkg/style/style.go b/installer/go/pkg/style/style.go new file mode 100644 index 00000000..439f25d7 --- /dev/null +++ b/installer/go/pkg/style/style.go @@ -0,0 +1,117 @@ +// Package style provides chalk-like terminal text styling that degrades +// gracefully. When stdout is not an interactive terminal (e.g. piped or +// redirected to a file), or when NO_COLOR / TERM=dumb are set, the helpers +// return their input unchanged so no escape sequences are emitted. +// +// Styling is intended for console output only. The logger package strips ANSI +// escape sequences before writing to its log file, so it is safe to wrap +// substrings passed to logger.Info/Debug/Error. +package style + +import ( + "fmt" + "os" + "sync/atomic" +) + +// promptSaved tracks whether a cursor position has been saved (via SaveCursor) +// but not yet restored. A signal handler uses this to leave the terminal clean +// if the user interrupts (Ctrl-C) while a prompt is on screen. +var promptSaved atomic.Bool + +var ( + // stdoutIsTerminal reports whether stdout is an interactive terminal. + stdoutIsTerminal = detectTerminal() + + // termIsDumb is true for terminals that don't understand ANSI sequences. + termIsDumb = os.Getenv("TERM") == "dumb" + + // Enabled reports whether ANSI colour styling will be emitted. It is computed + // once from stdout and the environment (respects NO_COLOR and TERM=dumb). + Enabled = stdoutIsTerminal && !termIsDumb && os.Getenv("NO_COLOR") == "" + + // controlEnabled reports whether cursor-control sequences may be emitted. + // Unlike colour, this is not disabled by NO_COLOR (which concerns colour only). + controlEnabled = stdoutIsTerminal && !termIsDumb +) + +func detectTerminal() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + // A character device is our proxy for "attached to a terminal". + return fi.Mode()&os.ModeCharDevice != 0 +} + +// SaveCursor records the current cursor position (DEC save-cursor) so a later +// RestoreAndClear can return to it. It is a no-op when stdout is not a +// control-capable terminal (piped/redirected or TERM=dumb), so it may be called +// unconditionally. +func SaveCursor() { + if !controlEnabled { + return + } + promptSaved.Store(true) + fmt.Fprint(os.Stdout, "\x1b7") // DECSC +} + +// RestoreAndClear returns the cursor to the position recorded by the most recent +// SaveCursor and erases everything printed since (from the cursor to the end of +// the screen). It is a no-op when stdout is not a control-capable terminal. +// +// Typical use: SaveCursor before printing a prompt, then RestoreAndClear once +// the response has been read, to collapse the prompt out of the scrollback. +// Unlike counting lines, this is unaffected by how the prompt wraps. Note it +// relies on a single saved position, so it must not be nested, and a very long +// prompt that scrolls the screen between save and restore may misalign. +func RestoreAndClear() { + if !controlEnabled { + return + } + promptSaved.Store(false) + fmt.Fprint(os.Stdout, "\x1b8\x1b[0J") // DECRC + ED(0): erase to end of screen +} + +// CancelPrompt leaves the terminal in a clean state after an interruption (e.g. +// Ctrl-C) while a prompt is on screen. If a cursor position was saved but not +// yet restored, it pairs that save with a restore + erase so no dangling +// save-cursor state is left behind (which would otherwise corrupt cursor +// handling until the terminal is reset). It always re-shows the cursor and +// resets text attributes. Safe to call from a signal handler and when no prompt +// is active; it is a no-op on non-control terminals. +func CancelPrompt() { + if !controlEnabled { + return + } + if promptSaved.Swap(false) { + fmt.Fprint(os.Stdout, "\x1b8\x1b[0J") // restore to prompt start and erase it + } + fmt.Fprint(os.Stdout, "\x1b[?25h\x1b[0m") // show cursor, reset attributes +} + +// wrap applies the given SGR parameter(s) to s when styling is enabled. +func wrap(codes, s string) string { + if !Enabled { + return s + } + return "\x1b[" + codes + "m" + s + "\x1b[0m" +} + +// Bold renders s in bold. Mirrors chalk.bold. +func Bold(s string) string { return wrap("1", s) } + +// Dim renders s dimmed. Mirrors chalk.dim. +func Dim(s string) string { return wrap("2", s) } + +// Cyan renders s in cyan. Mirrors chalk.cyan. +func Cyan(s string) string { return wrap("36", s) } + +// Green renders s in green. Mirrors chalk.green. +func Green(s string) string { return wrap("32", s) } + +// Yellow renders s in yellow. Mirrors chalk.yellow. +func Yellow(s string) string { return wrap("33", s) } + +// Red renders s in red. Mirrors chalk.red. +func Red(s string) string { return wrap("31", s) } diff --git a/installer/go/pkg/style/style_windows.go b/installer/go/pkg/style/style_windows.go new file mode 100644 index 00000000..ca5bd08c --- /dev/null +++ b/installer/go/pkg/style/style_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package style + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// init enables ANSI escape sequence processing on the Windows console so the +// codes emitted by this package are interpreted rather than printed literally. +// On consoles that don't support it this is a harmless no-op. +func init() { + handle := windows.Handle(os.Stdout.Fd()) + var mode uint32 + if err := windows.GetConsoleMode(handle, &mode); err != nil { + return + } + _ = windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) +} diff --git a/installer/go/pkg/utils/utils.go b/installer/go/pkg/utils/utils.go index d5c1c560..e6ea3e20 100644 --- a/installer/go/pkg/utils/utils.go +++ b/installer/go/pkg/utils/utils.go @@ -14,6 +14,8 @@ import ( "strings" "github.com/flowfuse/device-agent-installer/pkg/logger" + "github.com/flowfuse/device-agent-installer/pkg/style" + "gopkg.in/yaml.v3" ) @@ -46,11 +48,15 @@ type DeviceConfig struct { func PromptYesNo(question string, defaultResponse bool) bool { reader := bufio.NewReader(os.Stdin) + // Mark where the prompt begins so it (and any invalid-input retries) can be + // collapsed once answered. + style.SaveCursor() + for { if defaultResponse { - fmt.Printf("%s (Y/n): ", question) + fmt.Printf("%s %s: ", style.Bold(question), style.Dim("Y/n")) } else { - fmt.Printf("%s (y/N): ", question) + fmt.Printf("%s %s: ", style.Bold(question), style.Dim("y/N")) } var err error response, err := reader.ReadString('\n') @@ -63,10 +69,13 @@ func PromptYesNo(question string, defaultResponse bool) bool { switch response { case "": + style.RestoreAndClear() return defaultResponse // Default to true for empty input (Yes is default) case "y", "yes": + style.RestoreAndClear() return true case "n", "no": + style.RestoreAndClear() return false } @@ -75,6 +84,44 @@ func PromptYesNo(question string, defaultResponse bool) bool { } } +// PromptText prompts the user for a single line of free-text input, returning a +// default value when the user just presses Enter. On terminals that support it, +// the prompt is collapsed once answered. +// +// Parameters: +// - question: The prompt to display to the user +// - defaultValue: The value returned when the user provides no input +// +// Returns: +// - string: The trimmed user input, or defaultValue when the input is empty +func PromptText(question, defaultValue string) string { + reader := bufio.NewReader(os.Stdin) + + // Mark where the prompt begins so it can be collapsed once answered. + style.SaveCursor() + if defaultValue != "" { + fmt.Printf("%s\n (%s): ", style.Bold(question), style.Dim(defaultValue)) + } else { + fmt.Printf("%s:\n ", style.Bold(question)) + } + + response, err := reader.ReadString('\n') + if err != nil { + logger.Error("Failed to read user input: %v", err) + return defaultValue + } + + // Collapse the prompt now that we have the answer. No-op on terminals that + // don't support cursor control. + style.RestoreAndClear() + + response = strings.TrimSpace(response) + if response == "" { + return defaultValue + } + return response +} + // PromptMultilineInput prompts the user for multiline input until they enter an empty line // This is useful for collecting configuration file content from the user // @@ -135,6 +182,10 @@ func PromptOption(question string, options []string, defaultIndex int) (int, err reader := bufio.NewReader(os.Stdin) + // Mark where the prompt begins so it (and any invalid-input retries) can be + // collapsed once answered. + style.SaveCursor() + for { fmt.Printf("%s\n", question) for i, option := range options { @@ -155,6 +206,7 @@ func PromptOption(question string, options []string, defaultIndex int) (int, err // Handle default selection (empty input) if response == "" { + style.RestoreAndClear() return defaultIndex, nil } @@ -172,6 +224,7 @@ func PromptOption(question string, options []string, defaultIndex int) (int, err continue } + style.RestoreAndClear() return selectedIndex, nil } } @@ -215,8 +268,10 @@ func CheckPermissions() error { // - nil when sudo is usable (passwordless or credentials cached) // - error if sudo is missing or interactive authentication fails func checkUnixPermissions() error { - logger.Info("This installer will perform operations that require sudo.") - logger.Info("You may be prompted for your password if required.") + logger.Info("") + logger.Info("The installer may require administrator privileges for some steps.") + logger.Info("You will be prompted for your password if required.") + logger.Info("") if !checkBinaryExists("sudo", false) { return fmt.Errorf("sudo is not installed or not found in PATH")