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/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 +} diff --git a/cmd/update/update_windows.go b/cmd/update/update_windows.go index 5da4fcc..97f158b 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 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 + } + + 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/icons/icon-orange.ico b/icons/icon-orange.ico new file mode 100644 index 0000000..64fd61a Binary files /dev/null and b/icons/icon-orange.ico differ 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/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..e651cf1 --- /dev/null +++ b/scripts/reset-version.sh @@ -0,0 +1,77 @@ +#!/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" +MAKEFILE_PATH="${REPO_ROOT}/Makefile" + +DEFAULT_CLI_VERSION="version_replaceme" +DEFAULT_INSTALLER_VERSION="0.0.0" +DEFAULT_MAKEFILE_VERSION="version_replaceme" + +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 + +if [ ! -f "$MAKEFILE_PATH" ]; then + echo "Error: $MAKEFILE_PATH 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 "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 a5a4297..fcb20b1 100755 --- a/scripts/set-version.sh +++ b/scripts/set-version.sh @@ -1,19 +1,36 @@ #!/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" +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" + 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 +39,56 @@ 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 + +if [ ! -f "$MAKEFILE_PATH" ]; then + echo "Error: $MAKEFILE_PATH not found" + exit 1 +fi + +echo "Setting version to: ${VERSION}" +echo "" + +echo "Updating $VERSION_FILE..." +case $(uname) in + 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 + +echo "Updating pangolin-cli.wxs..." 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/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 in $INSTALLER_WXS" + exit 1 +fi -if grep -q "const Version = \"$NEW_VERSION\"" "$VERSION_FILE"; then - echo "Version bumped to $NEW_VERSION in $VERSION_FILE" +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" + echo "Error: failed to update VERSION in $MAKEFILE_PATH" exit 1 fi + +echo "" +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"