From 8c5001efd503e120d3d017c1221529eb3c053cf8 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Apr 2026 15:41:14 -0700 Subject: [PATCH 1/4] support full openssh flag passthrough for ssh --- cmd/ssh/exec_args.go | 26 +++++++ cmd/ssh/exec_ssh_env.go | 17 +++++ cmd/ssh/jit.go | 2 +- cmd/ssh/openssh_passthrough.go | 121 +++++++++++++++++++++++++++++++++ cmd/ssh/runner_exec_unix.go | 42 +++--------- cmd/ssh/runner_exec_windows.go | 47 ++++--------- cmd/ssh/runner_opts.go | 10 +++ cmd/ssh/sign.go | 1 + cmd/ssh/ssh.go | 48 +++++++------ cmd/ssh/ssh_osargs.go | 87 ++++++++++++++++++++++++ 10 files changed, 311 insertions(+), 90 deletions(-) create mode 100644 cmd/ssh/exec_args.go create mode 100644 cmd/ssh/exec_ssh_env.go create mode 100644 cmd/ssh/openssh_passthrough.go create mode 100644 cmd/ssh/runner_opts.go create mode 100644 cmd/ssh/ssh_osargs.go diff --git a/cmd/ssh/exec_args.go b/cmd/ssh/exec_args.go new file mode 100644 index 0000000..9673539 --- /dev/null +++ b/cmd/ssh/exec_args.go @@ -0,0 +1,26 @@ +package ssh + +import "strconv" + +// buildExecSSHArgs assembles argv for the system ssh(1) binary: +// +// ssh ... +func buildExecSSHArgs(sshPath, user, hostname string, port int, keyPath, certPath string, pass SSHPassthrough) []string { + args := []string{sshPath} + if user != "" { + args = append(args, "-l", user) + } + if keyPath != "" { + args = append(args, "-i", keyPath) + } + if certPath != "" { + args = append(args, "-o", "CertificateFile="+certPath) + } + if port > 0 { + args = append(args, "-p", strconv.Itoa(port)) + } + args = append(args, pass.Options...) + args = append(args, hostname) + args = append(args, pass.RemoteCommand...) + return args +} diff --git a/cmd/ssh/exec_ssh_env.go b/cmd/ssh/exec_ssh_env.go new file mode 100644 index 0000000..8ca02ea --- /dev/null +++ b/cmd/ssh/exec_ssh_env.go @@ -0,0 +1,17 @@ +package ssh + +import ( + "os" + "strings" +) + +// envSSHBinary overrides the ssh(1) executable used by RunExec on all platforms when non-empty. +const envSSHBinary = "PANGOLIN_SSH_BINARY" + +func sshBinaryFromEnv() (path string, ok bool) { + p := strings.TrimSpace(os.Getenv(envSSHBinary)) + if p == "" { + return "", false + } + return p, true +} diff --git a/cmd/ssh/jit.go b/cmd/ssh/jit.go index 5a8ba86..6f9917f 100644 --- a/cmd/ssh/jit.go +++ b/cmd/ssh/jit.go @@ -28,7 +28,7 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (pr if err != nil { return "", "", "", nil, fmt.Errorf("SSH error: %w", err) } - + // Collect all message IDs to poll (support both single and multiple). var messageIDs []int64 if len(initResp.MessageIDs) > 0 { diff --git a/cmd/ssh/openssh_passthrough.go b/cmd/ssh/openssh_passthrough.go new file mode 100644 index 0000000..6f8b3cb --- /dev/null +++ b/cmd/ssh/openssh_passthrough.go @@ -0,0 +1,121 @@ +package ssh + +import "strings" + +type SSHPassthrough struct { + Options []string + RemoteCommand []string +} + +// ParseOpenSSHPassThrough walks pass-through args (e.g. args[1:] from "pangolin ssh ..."). +func ParseOpenSSHPassThrough(args []string) SSHPassthrough { + if len(args) == 0 { + return SSHPassthrough{} + } + var opts []string + i := 0 + for i < len(args) { + a := args[i] + if a == "--" { + opts = append(opts, a) + i++ + return SSHPassthrough{Options: opts, RemoteCommand: cloneStringSliceOrNil(args[i:])} + } + if !strings.HasPrefix(a, "-") { + break + } + ex := openSSHOptionExtras(a, args, i) + end := i + 1 + ex + if end > len(args) { + end = len(args) + } + opts = append(opts, args[i:end]...) + i = end + } + return SSHPassthrough{Options: opts, RemoteCommand: cloneStringSliceOrNil(args[i:])} +} + +func cloneStringSliceOrNil(s []string) []string { + if len(s) == 0 { + return nil + } + return append([]string{}, s...) +} + +// openSSHOptionExtras returns how many args after the current token should be part of the same +// option (0 = only the current token, e.g. -N; 1 = one following value, e.g. -F path). +func openSSHOptionExtras(a string, args []string, i int) int { + if a == "" || a == "--" { + return 0 + } + // long options + if len(a) > 1 && a[0] == '-' && a[1] == '-' { + if strings.Contains(a, "=") { + return 0 + } + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") && longOpenSSHWithArg(a) { + return 1 + } + return 0 + } + if !strings.HasPrefix(a, "-") || a == "-" { + return 0 + } + // exactly two runes: e.g. -N, -L, -1, -2 + if len(a) == 2 { + switch a[1] { + case '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'N', 'G', 'T', 'C', 'f', 'g', 'n', 'q', 's', 't', 'v', 'x', 'X', 'Y', 'A', 'a', 'M', 'Q', 'V', 'y': + return 0 + case 'B', 'b', 'c', 'e', 'E', 'F', 'h', 'I', 'J', 'K', 'L', 'm', 'O', 'o', 'P', 'R', 'S', 'W', 'D', 'i', 'l', 'p', 'U': + return 1 + } + return 0 + } + // combined short token + if a[0] == '-' { + c := a[1] + switch c { + case 'D': + // -D, -D1080, -D[bind]:port + if len(a) == 2 { + return 1 + } + return 0 + case 'L', 'R': + // -L, -L8080:host:port + if len(a) == 2 { + return 1 + } + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + // e.g. -L with space then spec; if no ':' in a[2:] treat next as spec + if !strings.Contains(a[2:], ":") { + return 1 + } + } + return 0 + case 'O': + // -O with command (single token) or -O and next + if len(a) == 2 && i+1 < len(args) { + return 1 + } + return 0 + } + } + return 0 +} + +func longOpenSSHWithArg(s string) bool { + known := map[string]struct{}{ + "--bind-address": {}, + "--ciphers": {}, + "--kex": {}, + "--kexalgorithms": {}, + "--log-level": {}, + "--macs": {}, + "--keygen": {}, + "--user": {}, + } + _, ok := known[strings.ToLower(s)] + return ok +} diff --git a/cmd/ssh/runner_exec_unix.go b/cmd/ssh/runner_exec_unix.go index 335cba0..c25ef07 100644 --- a/cmd/ssh/runner_exec_unix.go +++ b/cmd/ssh/runner_exec_unix.go @@ -1,15 +1,16 @@ +//go:build !windows // +build !windows package ssh import ( "errors" + "fmt" "io" "os" "os/exec" "os/signal" "runtime" - "strconv" "syscall" "github.com/creack/pty" @@ -25,6 +26,12 @@ var execSSHSearchPaths = []string{ } func findExecSSHPath() (string, error) { + if p, ok := sshBinaryFromEnv(); ok { + if isExecutable(p) { + return p, nil + } + return "", fmt.Errorf("%s=%q: not an executable file", envSSHBinary, p) + } if path, err := exec.LookPath("ssh"); err == nil { return path, nil } @@ -57,18 +64,6 @@ func execExitCode(err error) int { return 1 } -// RunOpts is shared by both the exec and native SSH runners. -// PrivateKeyPEM and Certificate are set just-in-time (JIT) before connect; no file paths. -// Port is optional: 0 means use default (22 or whatever is in Hostname); >0 overrides. -type RunOpts struct { - User string - Hostname string - Port int // optional; 0 = default - PrivateKeyPEM string // in-memory private key (PEM, OpenSSH format) - Certificate string // in-memory certificate from sign-key API - PassThrough []string -} - // RunExec runs an interactive SSH session by executing the system ssh binary // (with a PTY when stdin is a terminal on Unix). Requires ssh to be installed. // opts.PrivateKeyPEM and opts.Certificate must be set (JIT key + signed cert). @@ -86,7 +81,7 @@ func RunExec(opts RunOpts) (int, error) { defer cleanup() } - argv := buildExecSSHArgs(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.PassThrough) + argv := buildExecSSHArgs(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.SSHPassthrough) cmd := exec.Command(argv[0], argv[1:]...) usePTY := runtime.GOOS != "windows" && isatty.IsTerminal(os.Stdin.Fd()) @@ -152,25 +147,6 @@ func writeExecKeyFiles(opts RunOpts) (keyPath, certPath string, cleanup func(), return keyPath, certPath, cleanup, nil } -func buildExecSSHArgs(sshPath, user, hostname string, port int, keyPath, certPath string, passThrough []string) []string { - args := []string{sshPath} - if user != "" { - args = append(args, "-l", user) - } - if keyPath != "" { - args = append(args, "-i", keyPath) - } - if certPath != "" { - args = append(args, "-o", "CertificateFile="+certPath) - } - if port > 0 { - args = append(args, "-p", strconv.Itoa(port)) - } - args = append(args, hostname) - args = append(args, passThrough...) - return args -} - func runExecWithPTY(cmd *exec.Cmd) (int, error) { // Put local terminal in raw mode so Ctrl+C and Tab are sent as bytes to the // remote instead of triggering SIGINT or local completion. diff --git a/cmd/ssh/runner_exec_windows.go b/cmd/ssh/runner_exec_windows.go index e94e54e..470e864 100644 --- a/cmd/ssh/runner_exec_windows.go +++ b/cmd/ssh/runner_exec_windows.go @@ -5,9 +5,9 @@ package ssh import ( "errors" + "fmt" "os" "os/exec" - "strconv" "golang.org/x/sys/windows" ) @@ -18,6 +18,16 @@ var execSSHSearchPaths = []string{ } func findExecSSHPathWindows() (string, error) { + if p, ok := sshBinaryFromEnv(); ok { + info, err := os.Stat(p) + if err != nil { + return "", fmt.Errorf("%s=%q: %w", envSSHBinary, p, err) + } + if info.IsDir() { + return "", fmt.Errorf("%s=%q: is a directory", envSSHBinary, p) + } + return p, nil + } if path, err := exec.LookPath("ssh"); err == nil { return path, nil } @@ -39,20 +49,8 @@ func execExitCode(err error) int { return 1 } -// RunOpts is shared by both the exec and native SSH runners. -// PrivateKeyPEM and Certificate are set just-in-time (JIT) before connect; no file paths. -// Port is optional: 0 means use default (22 or whatever is in Hostname); >0 overrides. -type RunOpts struct { - User string - Hostname string - Port int // optional; 0 = default - PrivateKeyPEM string // in-memory private key (PEM, OpenSSH format) - Certificate string // in-memory certificate from sign-key API - PassThrough []string -} - // RunExec runs an interactive SSH session by executing the system ssh binary. -// On Windows the system SSH has better support (e.g. terminal, agent). Requires ssh to be installed. +// Requires ssh to be installed (e.g. OpenSSH on Windows in PATH or System32). // opts.PrivateKeyPEM and opts.Certificate must be set (JIT key + signed cert). func RunExec(opts RunOpts) (int, error) { sshPath, err := findExecSSHPathWindows() @@ -68,7 +66,7 @@ func RunExec(opts RunOpts) (int, error) { defer cleanup() } - argv := buildExecSSHArgsWindows(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.PassThrough) + argv := buildExecSSHArgs(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.SSHPassthrough) cmd := exec.Command(argv[0], argv[1:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -185,22 +183,3 @@ func writeExecKeyFilesWindows(opts RunOpts) (keyPath, certPath string, cleanup f } return keyPath, certPath, cleanup, nil } - -func buildExecSSHArgsWindows(sshPath, user, hostname string, port int, keyPath, certPath string, passThrough []string) []string { - args := []string{sshPath} - if user != "" { - args = append(args, "-l", user) - } - if keyPath != "" { - args = append(args, "-i", keyPath) - } - if certPath != "" { - args = append(args, "-o", "CertificateFile="+certPath) - } - if port > 0 { - args = append(args, "-p", strconv.Itoa(port)) - } - args = append(args, hostname) - args = append(args, passThrough...) - return args -} diff --git a/cmd/ssh/runner_opts.go b/cmd/ssh/runner_opts.go new file mode 100644 index 0000000..c57e45f --- /dev/null +++ b/cmd/ssh/runner_opts.go @@ -0,0 +1,10 @@ +package ssh + +type RunOpts struct { + User string + Hostname string + Port int + PrivateKeyPEM string + Certificate string + SSHPassthrough +} diff --git a/cmd/ssh/sign.go b/cmd/ssh/sign.go index 2c4de92..671fef1 100644 --- a/cmd/ssh/sign.go +++ b/cmd/ssh/sign.go @@ -96,6 +96,7 @@ func SignCmd() *cobra.Command { } fmt.Println("Usage with system ssh (scp, tunnels, etc.):") fmt.Printf(" ssh -i %q -o CertificateFile=%q %s@%s\n", keyPath, certPath, user, hostname) + fmt.Printf(" ssh -i %q -o CertificateFile=%q -L 8080:127.0.0.1:80 -N %s@%s\n", keyPath, certPath, user, hostname) fmt.Printf(" scp -i %q -o CertificateFile=%q ...\n", keyPath, certPath) }, } diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index add6113..bef3100 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -3,7 +3,6 @@ package ssh import ( "errors" "os" - "runtime" "time" "github.com/fosrl/cli/internal/api" @@ -24,14 +23,21 @@ var ( func SSHCmd() *cobra.Command { opts := struct { ResourceID string - Exec bool + Builtin bool Port int }{} cmd := &cobra.Command{ Use: "ssh ", Short: "Run an interactive SSH session", - Long: `Run an SSH client in the terminal. Generates a key pair and signs it just-in-time, then connects to the target resource.`, + FParseErrWhitelist: cobra.FParseErrWhitelist{ + UnknownFlags: true, // -L, -R, and other ssh(1) flags are forwarded to the system OpenSSH client + }, + Long: `Run an SSH client in the terminal. Generates a key pair and signs it just-in-time, then connects to the target resource. + +By default the system OpenSSH client is used on every platform. You can pass the same options as ssh(1) after the resource name (for example port forwards: -L, -R, -D, and -N), then an optional remote command. Example: pangolin ssh -L 8080:127.0.0.1:80 -N + +Set PANGOLIN_SSH_BINARY to the full path of ssh(1) to override PATH lookup on all platforms.`, PreRunE: func(c *cobra.Command, args []string) error { if len(args) < 1 || args[0] == "" { return errResourceIDRequired @@ -70,9 +76,7 @@ func SSHCmd() *cobra.Command { logger.Error("%v", errHostnameRequired) os.Exit(1) } - - // logger.Info("signData: %+v", signData) - + siteIDs := []int{} if signData.SiteID != 0 { siteIDs = append(siteIDs, signData.SiteID) @@ -105,26 +109,26 @@ func SSHCmd() *cobra.Command { } } - passThrough := args[1:] + passThrough := mergePassThrough(os.Args, opts.ResourceID, args[1:]) + pt := ParseOpenSSHPassThrough(passThrough) runOpts := RunOpts{ - User: signData.User, - Hostname: signData.Hostname, - Port: opts.Port, - PrivateKeyPEM: privPEM, - Certificate: cert, - PassThrough: passThrough, + User: signData.User, + Hostname: signData.Hostname, + Port: opts.Port, + PrivateKeyPEM: privPEM, + Certificate: cert, + SSHPassthrough: pt, } - // On Windows, use the system ssh binary by default (better terminal/agent support). - useExec := opts.Exec || runtime.GOOS == "windows" - if len(passThrough) > 0 && !useExec { - logger.Warning("Passthrough arguments are ignored by the built-in client. Use --exec to pass them to the system ssh.") + useBuiltin := opts.Builtin + if len(passThrough) > 0 && useBuiltin { + logger.Warning("Extra arguments after the resource are ignored by the built-in client (port forwarding, remote commands, and other ssh(1) options). Omit --builtin to use the system OpenSSH client.") } var exitCode int - if useExec { - exitCode, err = RunExec(runOpts) - } else { + if useBuiltin { exitCode, err = RunNative(runOpts) + } else { + exitCode, err = RunExec(runOpts) } if err != nil { logger.Error("%v", err) @@ -134,8 +138,8 @@ func SSHCmd() *cobra.Command { }, } - cmd.Flags().BoolVar(&opts.Exec, "exec", false, "Use system ssh binary instead of the built-in client") - cmd.Flags().IntVarP(&opts.Port, "port", "p", 0, "SSH port (default: 22)") + cmd.Flags().BoolVar(&opts.Builtin, "builtin", false, "Use the built-in SSH client instead of the system OpenSSH binary (interactive shell only)") + cmd.Flags().IntVarP(&opts.Port, "port", "p", 0, "Remote SSH port (default: 22)") cmd.AddCommand(SignCmd()) diff --git a/cmd/ssh/ssh_osargs.go b/cmd/ssh/ssh_osargs.go new file mode 100644 index 0000000..88ea616 --- /dev/null +++ b/cmd/ssh/ssh_osargs.go @@ -0,0 +1,87 @@ +package ssh + +import ( + "strconv" + + "github.com/fosrl/cli/internal/logger" +) + +func sshPassThroughFromOS(osArgs []string, resourceID string) []string { + if len(osArgs) == 0 || resourceID == "" { + return nil + } + sshIdx := -1 + for i := 0; i < len(osArgs); i++ { + if osArgs[i] == "ssh" { + sshIdx = i + } + } + if sshIdx < 0 || sshIdx+1 >= len(osArgs) { + return nil + } + tail := append([]string{}, osArgs[sshIdx+1:]...) + tail = stripPangolinSSHKnownFlags(tail) + tail = removeFirstTokenEqual(tail, resourceID) + return tail +} + +// stripPangolinSSHKnownFlags removes pangolin-only tokens so they are not forwarded to ssh(1). +// --port / -p are parsed by Cobra and applied via RunOpts.Port; stripping avoids duplicate port +// flags in the ssh(1) argv built from passthrough. +func stripPangolinSSHKnownFlags(in []string) []string { + out := make([]string, 0, len(in)) + saidLegacyExec := false + for i := 0; i < len(in); { + switch { + case in[i] == "--exec": + if !saidLegacyExec { + saidLegacyExec = true + logger.Info("Note: --exec is no longer needed; the system OpenSSH client is the default. This flag is ignored and not passed to ssh(1).\n") + } + i++ + case in[i] == "--builtin": + i++ + case in[i] == "--port" && i+1 < len(in): + i += 2 + case in[i] == "-p" && i+1 < len(in) && isAllDigits(in[i+1]): + i += 2 + default: + out = append(out, in[i]) + i++ + } + } + return out +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + _, err := strconv.Atoi(s) + return err == nil +} + +func removeFirstTokenEqual(in []string, id string) []string { + for i, s := range in { + if s == id { + return append(append([]string{}, in[:i]...), in[i+1:]...) + } + } + return append([]string(nil), in...) +} + +func mergePassThrough(osArgs []string, resourceID string, cobraTail []string) []string { + fromOS := sshPassThroughFromOS(osArgs, resourceID) + if len(fromOS) > 0 { + return fromOS + } + if len(cobraTail) > 0 { + return append([]string(nil), cobraTail...) + } + return nil +} From 158d1d2f95c233b5b8318c07376ca590fa459678 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 26 Apr 2026 17:44:14 -0700 Subject: [PATCH 2/4] add wix installer and icon --- icons/icon-orange.ico | Bin 0 -> 112912 bytes pangolin-cli.iss | 152 --------------------------------------- pangolin-cli.wxs | 57 +++++++++++++++ scripts/build-msi.bat | 24 +++++++ scripts/reset-version.sh | 57 +++++++++++++++ scripts/set-version.sh | 62 +++++++++++++--- 6 files changed, 189 insertions(+), 163 deletions(-) create mode 100644 icons/icon-orange.ico delete mode 100644 pangolin-cli.iss create mode 100644 pangolin-cli.wxs create mode 100644 scripts/build-msi.bat create mode 100755 scripts/reset-version.sh diff --git a/icons/icon-orange.ico b/icons/icon-orange.ico new file mode 100644 index 0000000000000000000000000000000000000000..64fd61a40732f62a73521df0d6291bcf460b22a6 GIT binary patch literal 112912 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 S`;FKD0000 literal 0 HcmV?d00001 diff --git a/pangolin-cli.iss b/pangolin-cli.iss deleted file mode 100644 index a8e7bdd..0000000 --- a/pangolin-cli.iss +++ /dev/null @@ -1,152 +0,0 @@ -; Script generated by the Inno Setup Script Wizard. -; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! - -#define MyAppName "pangolin-cli" -#define MyAppVersion "1.0.0" -#define MyAppPublisher "Fossorial Inc." -#define MyAppURL "https://pangolin.net" -#define MyAppExeName "pangolin.exe" - -[Setup] -; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. -; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) -AppId={{35A1E3C4-C273-4334-9DF3-57408E83012E} -AppName={#MyAppName} -AppVersion={#MyAppVersion} -;AppVerName={#MyAppName} {#MyAppVersion} -AppPublisher={#MyAppPublisher} -AppPublisherURL={#MyAppURL} -AppSupportURL={#MyAppURL} -AppUpdatesURL={#MyAppURL} -DefaultDirName={autopf}\{#MyAppName} -UninstallDisplayIcon={app}\{#MyAppExeName} -; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run -; on anything but x64 and Windows 11 on Arm. -ArchitecturesAllowed=x64compatible -; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the -; install be done in "64-bit mode" on x64 or Windows 11 on Arm, -; meaning it should use the native 64-bit Program Files directory and -; the 64-bit view of the registry. -ArchitecturesInstallIn64BitMode=x64compatible -DefaultGroupName={#MyAppName} -DisableProgramGroupPage=yes -; Uncomment the following line to run in non administrative install mode (install for current user only). -;PrivilegesRequired=lowest -OutputBaseFilename=pangolin-cli_windows_installer -SolidCompression=yes -WizardStyle=modern -; Add this to ensure PATH changes are applied and the system is prompted for a restart if needed -RestartIfNeededByRun=no -ChangesEnvironment=true - -[Languages] -Name: "english"; MessagesFile: "compiler:Default.isl" - -[Files] -; The 'DestName' flag ensures that 'pangolin-cli_windows_amd64.exe' is installed as 'pangolin-cli.exe' -Source: "Z:\pangolin-cli_windows_amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}"; Flags: ignoreversion -Source: "Z:\wintun.dll"; DestDir: "{app}"; Flags: ignoreversion -; NOTE: Don't use "Flags: ignoreversion" on any shared system files - -[Icons] -Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" - -[Registry] -; Add the application's installation directory to the system PATH environment variable. -; HKLM (HKEY_LOCAL_MACHINE) is used for system-wide changes. -; The 'Path' variable is located under 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'. -; ValueType: expandsz allows for environment variables (like %ProgramFiles%) in the path. -; ValueData: "{olddata};{app}" appends the current application directory to the existing PATH. -; Note: Removal during uninstallation is handled by CurUninstallStepChanged procedure in [Code] section. -; Check: NeedsAddPath ensures this is applied only if the path is not already present. -[Registry] -; Add the application's installation directory to the system PATH. -Root: HKLM; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; \ - ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \ - Check: NeedsAddPath(ExpandConstant('{app}')) - -[Code] -function NeedsAddPath(Path: string): boolean; -var - OrigPath: string; -begin - if not RegQueryStringValue(HKEY_LOCAL_MACHINE, - 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', - 'Path', OrigPath) - then begin - // Path variable doesn't exist at all, so we definitely need to add it. - Result := True; - exit; - end; - - // Perform a case-insensitive check to see if the path is already present. - // We add semicolons to prevent partial matches (e.g., matching C:\App in C:\App2). - if Pos(';' + UpperCase(Path) + ';', ';' + UpperCase(OrigPath) + ';') > 0 then - Result := False - else - Result := True; -end; - -procedure RemovePathEntry(PathToRemove: string); -var - OrigPath: string; - NewPath: string; - PathList: TStringList; - I: Integer; -begin - if not RegQueryStringValue(HKEY_LOCAL_MACHINE, - 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', - 'Path', OrigPath) - then begin - // Path variable doesn't exist, nothing to remove - exit; - end; - - // Create a string list to parse the PATH entries - PathList := TStringList.Create; - try - // Split the PATH by semicolons - PathList.Delimiter := ';'; - PathList.StrictDelimiter := True; - PathList.DelimitedText := OrigPath; - - // Find and remove the matching entry (case-insensitive) - for I := PathList.Count - 1 downto 0 do - begin - if CompareText(Trim(PathList[I]), Trim(PathToRemove)) = 0 then - begin - Log('Found and removing PATH entry: ' + PathList[I]); - PathList.Delete(I); - end; - end; - - // Reconstruct the PATH - NewPath := PathList.DelimitedText; - - // Write the new PATH back to the registry - if RegWriteExpandStringValue(HKEY_LOCAL_MACHINE, - 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', - 'Path', NewPath) - then - Log('Successfully removed path entry: ' + PathToRemove) - else - Log('Failed to write modified PATH to registry'); - finally - PathList.Free; - end; -end; - -procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); -var - AppPath: string; -begin - if CurUninstallStep = usUninstall then - begin - // Get the application installation path - AppPath := ExpandConstant('{app}'); - Log('Removing PATH entry for: ' + AppPath); - - // Remove only our path entry from the system PATH - RemovePathEntry(AppPath); - end; -end; diff --git a/pangolin-cli.wxs b/pangolin-cli.wxs new file mode 100644 index 0000000..7a91372 --- /dev/null +++ b/pangolin-cli.wxs @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/build-msi.bat b/scripts/build-msi.bat new file mode 100644 index 0000000..f6f40f4 --- /dev/null +++ b/scripts/build-msi.bat @@ -0,0 +1,24 @@ +@echo off +REM Build MSI for Pangolin CLI (WiX v4). Package Version comes from pangolin-cli.wxs (set via scripts\set-version.sh). +REM Requires the `wix` CLI on PATH. +REM 1) Build: make go-build-release-windows-amd64 VERSION=... +REM 2) Run from repo root: scripts\build-msi.bat +REM Or: scripts\build-msi.bat C:\path\to\folder-with-exe + +setlocal +cd /d "%~dp0.." + +if "%~1"=="" ( + set "BUILDDIR=bin" +) else ( + set "BUILDDIR=%~1" +) + +if not exist "%BUILDDIR%\pangolin-cli_windows_amd64.exe" ( + echo ERROR: %BUILDDIR%\pangolin-cli_windows_amd64.exe not found. Build the Windows binary first. 1>&2 + exit /b 1 +) + +wix build -arch x64 -define "BuildDir=%BUILDDIR%" -o "%BUILDDIR%\pangolin-cli_windows_installer.msi" "pangolin-cli.wxs" +if errorlevel 1 exit /b %errorlevel% +echo Created "%BUILDDIR%\pangolin-cli_windows_installer.msi" diff --git a/scripts/reset-version.sh b/scripts/reset-version.sh new file mode 100755 index 0000000..30a6283 --- /dev/null +++ b/scripts/reset-version.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Reset version placeholders in internal/version/consts.go and pangolin-cli.wxs +# Usage: ./reset-version.sh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +VERSION_FILE="${REPO_ROOT}/internal/version/consts.go" +INSTALLER_WXS="${REPO_ROOT}/pangolin-cli.wxs" + +DEFAULT_CLI_VERSION="version_replaceme" +DEFAULT_INSTALLER_VERSION="0.0.0" + +cd "$REPO_ROOT" + +if [ ! -f "$VERSION_FILE" ]; then + echo "Error: $VERSION_FILE not found" + exit 1 +fi + +if [ ! -f "$INSTALLER_WXS" ]; then + echo "Error: $INSTALLER_WXS not found" + exit 1 +fi + +echo "Resetting versions to defaults..." +echo "" + +echo "Updating $VERSION_FILE..." +case $(uname) in + Darwin) sed -i '' "s/var Version = \"[^\"]*\"/var Version = \"$DEFAULT_CLI_VERSION\"/" "$VERSION_FILE" ;; + *) sed -i "s/var Version = \"[^\"]*\"/var Version = \"$DEFAULT_CLI_VERSION\"/" "$VERSION_FILE" ;; +esac +if grep -q "var Version = \"$DEFAULT_CLI_VERSION\"" "$VERSION_FILE"; then + echo " OK" +else + echo "Error: failed to reset version in $VERSION_FILE" + exit 1 +fi + +echo "Updating pangolin-cli.wxs..." +case $(uname) in + Darwin) sed -i '' "s/Version=\"[^\"]*\"/Version=\"$DEFAULT_INSTALLER_VERSION\"/" "$INSTALLER_WXS" ;; + *) sed -i "s/Version=\"[^\"]*\"/Version=\"$DEFAULT_INSTALLER_VERSION\"/" "$INSTALLER_WXS" ;; +esac +if grep -q "Version=\"$DEFAULT_INSTALLER_VERSION\"" "$INSTALLER_WXS"; then + echo " OK" +else + echo "Error: failed to reset Version in $INSTALLER_WXS" + exit 1 +fi + +echo "" +echo "Versions reset:" +echo " internal/version/consts.go -> $DEFAULT_CLI_VERSION" +echo " pangolin-cli.wxs -> $DEFAULT_INSTALLER_VERSION" diff --git a/scripts/set-version.sh b/scripts/set-version.sh index a5a4297..9e07dc5 100755 --- a/scripts/set-version.sh +++ b/scripts/set-version.sh @@ -1,19 +1,35 @@ #!/usr/bin/env bash -# Bump the CLI version by replacing the version in internal/version/consts.go +# Set version in internal/version/consts.go and installer pangolin-cli.wxs +# Usage: ./set-version.sh +# version: Version string (e.g., 1.0.3) set -e -VERSION_FILE="internal/version/consts.go" - if [ -z "$1" ]; then echo "Usage: $0 " - echo "Example: $0 0.4.0" + echo " version: Version string (e.g., \"1.0.3\")" + echo "" + echo "Example:" + echo " $0 1.0.3" exit 1 fi -NEW_VERSION="$1" +VERSION="$1" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +VERSION_FILE="${REPO_ROOT}/internal/version/consts.go" +INSTALLER_WXS="${REPO_ROOT}/pangolin-cli.wxs" + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?(-[a-zA-Z0-9]+)?$ ]]; then + echo "Warning: Version format may be invalid. Expected format: X.Y.Z or X.Y.Z-suffix" + echo " Example: 1.0.3 or 1.0.3-beta" + read -p "Continue anyway? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 1 + fi +fi cd "$REPO_ROOT" @@ -22,15 +38,39 @@ if [ ! -f "$VERSION_FILE" ]; then exit 1 fi -# Replace the version in consts.go (matches: const Version = "X.Y.Z") +if [ ! -f "$INSTALLER_WXS" ]; then + echo "Error: $INSTALLER_WXS not found" + exit 1 +fi + +echo "Setting version to: ${VERSION}" +echo "" + +echo "Updating $VERSION_FILE..." case $(uname) in - Darwin) sed -i '' "s/const Version = \"[^\"]*\"/const Version = \"$NEW_VERSION\"/" "$VERSION_FILE" ;; - *) sed -i "s/const Version = \"[^\"]*\"/const Version = \"$NEW_VERSION\"/" "$VERSION_FILE" ;; + Darwin) sed -i '' "s/var Version = \"[^\"]*\"/var Version = \"$VERSION\"/" "$VERSION_FILE" ;; + *) sed -i "s/var Version = \"[^\"]*\"/var Version = \"$VERSION\"/" "$VERSION_FILE" ;; esac +if grep -q "var Version = \"$VERSION\"" "$VERSION_FILE"; then + echo " OK" +else + echo "Error: failed to update version in $VERSION_FILE" + exit 1 +fi -if grep -q "const Version = \"$NEW_VERSION\"" "$VERSION_FILE"; then - echo "Version bumped to $NEW_VERSION in $VERSION_FILE" +echo "Updating pangolin-cli.wxs..." +case $(uname) in + Darwin) sed -i '' "s/Version=\"[^\"]*\"/Version=\"$VERSION\"/" "$INSTALLER_WXS" ;; + *) sed -i "s/Version=\"[^\"]*\"/Version=\"$VERSION\"/" "$INSTALLER_WXS" ;; +esac +if grep -q "Version=\"$VERSION\"" "$INSTALLER_WXS"; then + echo " OK" else - echo "Error: failed to update version" + echo "Error: failed to update Version in $INSTALLER_WXS" exit 1 fi + +echo "" +echo "Version updated to $VERSION in internal/version/consts.go and pangolin-cli.wxs" +echo "" +echo "Next: build the Windows binary, then: scripts\\build-msi.bat" From dc7bc4e7dd1f6c78a06bff28005a810784df9441 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 26 Apr 2026 18:11:51 -0700 Subject: [PATCH 3/4] support update windows cli --- Makefile | 4 +- cmd/update/update_windows.go | 171 +++++++++++++++++++++++++++++++++-- internal/version/consts.go | 2 +- scripts/reset-version.sh | 20 ++++ scripts/set-version.sh | 20 +++- 5 files changed, 207 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 57b879b..06dda11 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ BINARY_NAME=pangolin OUTPUT_DIR=bin -VERSION ?= dev +VERSION ?= version_replaceme LDFLAGS = -s -w -X github.com/fosrl/cli/internal/version.Version=$(VERSION) all: clean build @@ -81,4 +81,4 @@ go-build-release-darwin-amd64: CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/pangolin-cli_darwin_amd64 go-build-release-windows-amd64: - CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/pangolin-cli_windows_amd64.exe \ No newline at end of file + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/pangolin-cli_windows_amd64.exe diff --git a/cmd/update/update_windows.go b/cmd/update/update_windows.go index 5da4fcc..6e5b199 100644 --- a/cmd/update/update_windows.go +++ b/cmd/update/update_windows.go @@ -3,32 +3,191 @@ package update import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" "os" + "os/exec" + "path/filepath" + "strings" + "time" "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/version" "github.com/spf13/cobra" ) +const windowsInstallerAssetName = "pangolin-cli_windows_installer.msi" + +var windowsUpdateRepo = "fosrl/cli" + +type githubReleaseAsset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` +} + +type githubReleaseResponse struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Assets []githubReleaseAsset `json:"assets"` +} + func UpdateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "update", Short: "Update Pangolin CLI to the latest version", Long: "Update Pangolin CLI to the latest version by downloading the new installer from GitHub", Run: func(cmd *cobra.Command, args []string) { - if err := updateMain(); err != nil { + if err := updateMain(windowsUpdateRepo); err != nil { os.Exit(1) } }, } + cmd.Flags().StringVar(&windowsUpdateRepo, "repo", windowsUpdateRepo, "GitHub repository in owner/name format") return cmd } -func updateMain() error { - logger.Info("To update Pangolin CLI on Windows, please download the latest installer from:") - logger.Info("https://github.com/fosrl/cli/releases") - logger.Info("") - logger.Info("Download and run the latest .msi or .exe installer to update to the newest version.") +func updateMain(repo string) error { + logger.Info("Checking for latest Pangolin CLI Windows installer...") + + release, err := getLatestRelease(repo) + if err != nil { + logger.Error("Failed to fetch latest release: %v", err) + return err + } + + installerURL, err := getInstallerURL(release.Assets) + if err != nil { + logger.Error("%v", err) + logger.Info("Release page: %s", release.HTMLURL) + return err + } + + logger.Info("This will download %s from GitHub (%s) to a temporary folder and then start the Windows installer.", windowsInstallerAssetName, repo) + logger.Info("Press Enter to continue...") + if err := waitForEnter(); err != nil { + logger.Error("Failed to read confirmation input: %v", err) + return err + } + + tempDir, err := os.MkdirTemp("", "pangolin-cli-update-*") + if err != nil { + logger.Error("Failed to create temp dir: %v", err) + return err + } + + installerPath := filepath.Join(tempDir, windowsInstallerAssetName) + logger.Info("Downloading %s from release %s...", windowsInstallerAssetName, release.TagName) + if err := downloadFile(installerURL, installerPath); err != nil { + logger.Error("Failed to download installer: %v", err) + return err + } + + logger.Info("Launching installer: %s", installerPath) + msiExecPath := filepath.Join(os.Getenv("WINDIR"), "System32", "msiexec.exe") + if _, statErr := os.Stat(msiExecPath); statErr != nil { + msiExecPath = "msiexec.exe" + } + + // Start detached so the update command can exit while installer continues. + installCmd := exec.Command(msiExecPath, "/i", installerPath) + installCmd.Stdout = os.Stdout + installCmd.Stderr = os.Stderr + if err := installCmd.Start(); err != nil { + logger.Error("Failed to start installer: %v", err) + return err + } + + logger.Success("Installer launched. Follow the MSI prompts to complete update.") + + return nil +} + +func waitForEnter() error { + reader := bufio.NewReader(os.Stdin) + _, err := reader.ReadString('\n') + return err +} + +func getLatestRelease(repo string) (*githubReleaseResponse, error) { + repoParts := strings.Split(repo, "/") + if len(repoParts) != 2 || repoParts[0] == "" || repoParts[1] == "" { + return nil, fmt.Errorf("invalid repo %q, expected owner/name", repo) + } + + url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", version.GitHubAPIBaseURL, repoParts[0], repoParts[1]) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create release request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "pangolin-cli-update") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to query GitHub releases: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub releases API returned %d: %s", resp.StatusCode, string(body)) + } + + var release githubReleaseResponse + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("failed to decode release response: %w", err) + } + + return &release, nil +} + +func getInstallerURL(assets []githubReleaseAsset) (string, error) { + for _, asset := range assets { + if asset.Name == windowsInstallerAssetName && asset.URL != "" { + return asset.URL, nil + } + } + + return "", fmt.Errorf("latest release does not include %s", windowsInstallerAssetName) +} + +func downloadFile(url string, destPath string) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create download request: %w", err) + } + + req.Header.Set("Accept", "application/octet-stream") + req.Header.Set("User-Agent", "pangolin-cli-update") + + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to download installer: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("installer download failed with %d: %s", resp.StatusCode, string(body)) + } + + out, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create installer file: %w", err) + } + defer out.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + return fmt.Errorf("failed to write installer file: %w", err) + } return nil } \ No newline at end of file diff --git a/internal/version/consts.go b/internal/version/consts.go index 7c083e3..0448803 100644 --- a/internal/version/consts.go +++ b/internal/version/consts.go @@ -4,4 +4,4 @@ package version // This value can be overridden at build time using ldflags: // // go build -ldflags "-X github.com/fosrl/cli/internal/version.Version=" -var Version = "version_replaceme" \ No newline at end of file +var Version = "version_replaceme" diff --git a/scripts/reset-version.sh b/scripts/reset-version.sh index 30a6283..e651cf1 100755 --- a/scripts/reset-version.sh +++ b/scripts/reset-version.sh @@ -8,9 +8,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" VERSION_FILE="${REPO_ROOT}/internal/version/consts.go" INSTALLER_WXS="${REPO_ROOT}/pangolin-cli.wxs" +MAKEFILE_PATH="${REPO_ROOT}/Makefile" DEFAULT_CLI_VERSION="version_replaceme" DEFAULT_INSTALLER_VERSION="0.0.0" +DEFAULT_MAKEFILE_VERSION="version_replaceme" cd "$REPO_ROOT" @@ -24,6 +26,11 @@ if [ ! -f "$INSTALLER_WXS" ]; then exit 1 fi +if [ ! -f "$MAKEFILE_PATH" ]; then + echo "Error: $MAKEFILE_PATH not found" + exit 1 +fi + echo "Resetting versions to defaults..." echo "" @@ -51,7 +58,20 @@ else exit 1 fi +echo "Updating $MAKEFILE_PATH..." +case $(uname) in + Darwin) sed -i '' "s/^VERSION[[:space:]]*?=[[:space:]]*.*/VERSION ?= $DEFAULT_MAKEFILE_VERSION/" "$MAKEFILE_PATH" ;; + *) sed -i "s/^VERSION[[:space:]]*?=[[:space:]]*.*/VERSION ?= $DEFAULT_MAKEFILE_VERSION/" "$MAKEFILE_PATH" ;; +esac +if grep -q "^VERSION[[:space:]]*?=[[:space:]]*$DEFAULT_MAKEFILE_VERSION$" "$MAKEFILE_PATH"; then + echo " OK" +else + echo "Error: failed to reset VERSION in $MAKEFILE_PATH" + exit 1 +fi + echo "" echo "Versions reset:" echo " internal/version/consts.go -> $DEFAULT_CLI_VERSION" echo " pangolin-cli.wxs -> $DEFAULT_INSTALLER_VERSION" +echo " Makefile -> $DEFAULT_MAKEFILE_VERSION" diff --git a/scripts/set-version.sh b/scripts/set-version.sh index 9e07dc5..fcb20b1 100755 --- a/scripts/set-version.sh +++ b/scripts/set-version.sh @@ -19,6 +19,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" VERSION_FILE="${REPO_ROOT}/internal/version/consts.go" INSTALLER_WXS="${REPO_ROOT}/pangolin-cli.wxs" +MAKEFILE_PATH="${REPO_ROOT}/Makefile" if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?(-[a-zA-Z0-9]+)?$ ]]; then echo "Warning: Version format may be invalid. Expected format: X.Y.Z or X.Y.Z-suffix" @@ -43,6 +44,11 @@ if [ ! -f "$INSTALLER_WXS" ]; then exit 1 fi +if [ ! -f "$MAKEFILE_PATH" ]; then + echo "Error: $MAKEFILE_PATH not found" + exit 1 +fi + echo "Setting version to: ${VERSION}" echo "" @@ -70,7 +76,19 @@ else exit 1 fi +echo "Updating $MAKEFILE_PATH..." +case $(uname) in + Darwin) sed -i '' "s/^VERSION[[:space:]]*?=[[:space:]]*.*/VERSION ?= $VERSION/" "$MAKEFILE_PATH" ;; + *) sed -i "s/^VERSION[[:space:]]*?=[[:space:]]*.*/VERSION ?= $VERSION/" "$MAKEFILE_PATH" ;; +esac +if grep -q "^VERSION[[:space:]]*?=[[:space:]]*$VERSION$" "$MAKEFILE_PATH"; then + echo " OK" +else + echo "Error: failed to update VERSION in $MAKEFILE_PATH" + exit 1 +fi + echo "" -echo "Version updated to $VERSION in internal/version/consts.go and pangolin-cli.wxs" +echo "Version updated to $VERSION in internal/version/consts.go, pangolin-cli.wxs, and Makefile" echo "" echo "Next: build the Windows binary, then: scripts\\build-msi.bat" From 6dbf513cd73c69c47c0788fe53d3340f81cc42cf Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 26 Apr 2026 20:59:10 -0700 Subject: [PATCH 4/4] add confirmation dialog pre update --- cmd/update/update_windows.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/update/update_windows.go b/cmd/update/update_windows.go index 6e5b199..97f158b 100644 --- a/cmd/update/update_windows.go +++ b/cmd/update/update_windows.go @@ -66,8 +66,8 @@ func updateMain(repo string) error { return err } - logger.Info("This will download %s from GitHub (%s) to a temporary folder and then start the Windows installer.", windowsInstallerAssetName, repo) - logger.Info("Press Enter to continue...") + logger.Info("This will download the latest version to a temporary folder, then start the Windows installer.") + logger.Info("Press Enter to confirm...") if err := waitForEnter(); err != nil { logger.Error("Failed to read confirmation input: %v", err) return err @@ -190,4 +190,4 @@ func downloadFile(url string, destPath string) error { } return nil -} \ No newline at end of file +}