From 1b0aeee62feab223858a9339f2593c1bcfcf5fd2 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 29 Apr 2026 20:45:37 +0200 Subject: [PATCH] fix: avoid powershell browser opener on wsl --- .gitignore | 3 + .goreleaser.yml | 16 +++-- cmd/grounds/commands/login.go | 2 +- go.mod | 3 +- go.sum | 3 - internal/browser/open.go | 93 ++++++++++++++++++++++++++ internal/browser/open_test.go | 121 ++++++++++++++++++++++++++++++++++ 7 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 internal/browser/open.go create mode 100644 internal/browser/open_test.go diff --git a/.gitignore b/.gitignore index aa8c2fd..3a23e98 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ *.swp .env .env.* + +# Codex +.codex diff --git a/.goreleaser.yml b/.goreleaser.yml index a4b3ca8..6a26f7e 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -28,7 +28,7 @@ archives: name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" format_overrides: - goos: windows - format: zip + formats: [zip] checksum: name_template: "checksums.txt" @@ -44,7 +44,7 @@ nfpms: formats: [deb, rpm] bindir: /usr/bin -brews: +homebrew_casks: - repository: owner: groundsgg name: homebrew-tap @@ -52,14 +52,16 @@ brews: # exposed as HOMEBREW_TAP_PAT in the workflow. token: "{{ .Env.HOMEBREW_TAP_PAT }}" name: grounds + binaries: + - grounds homepage: https://grounds.gg description: Grounds Internal Developer Platform CLI license: Apache-2.0 - install: | - bin.install "grounds" - generate_completions_from_executable(bin/"grounds", "completion") - test: | - assert_match "grounds version", shell_output("#{bin}/grounds version") + generate_completions_from_executable: + executable: "bin/grounds" + args: + - completion + shell_parameter_format: cobra scoops: - repository: diff --git a/cmd/grounds/commands/login.go b/cmd/grounds/commands/login.go index dab4b15..5f69be3 100644 --- a/cmd/grounds/commands/login.go +++ b/cmd/grounds/commands/login.go @@ -9,10 +9,10 @@ import ( "strings" "time" - "github.com/pkg/browser" "github.com/spf13/cobra" "github.com/groundsgg/grounds-cli/internal/auth" + "github.com/groundsgg/grounds-cli/internal/browser" "github.com/groundsgg/grounds-cli/internal/config" ) diff --git a/go.mod b/go.mod index 656bcd0..75cf518 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/groundsgg/grounds-cli go 1.25.0 require ( + github.com/charmbracelet/huh v1.0.0 github.com/fatih/color v1.19.0 github.com/jedib0t/go-pretty/v6 v6.7.10 - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/r3labs/sse/v2 v2.10.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -22,7 +22,6 @@ require ( github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/huh v1.0.0 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect diff --git a/go.sum b/go.sum index 6eb2016..df8d401 100644 --- a/go.sum +++ b/go.sum @@ -88,8 +88,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0= @@ -138,7 +136,6 @@ golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/browser/open.go b/internal/browser/open.go new file mode 100644 index 0000000..0b01d95 --- /dev/null +++ b/internal/browser/open.go @@ -0,0 +1,93 @@ +package browser + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "runtime" +) + +type commandSpec struct { + name string + args []string +} + +var startProcess = defaultStartProcess + +// OpenURL opens url in the user's browser. On WSL, avoid PowerShell-based +// launchers because their console encoding setup can write spurious errors. +func OpenURL(url string) error { + spec, err := commandForURL(runtime.GOOS, os.Getenv, exec.LookPath, url) + if err != nil { + return err + } + + return launchCommand(spec) +} + +func launchCommand(spec commandSpec) error { + wait, err := startProcess(spec) + if err != nil { + return err + } + go func() { + _ = wait() + }() + return nil +} + +func defaultStartProcess(spec commandSpec) (func() error, error) { + cmd := exec.CommandContext(context.Background(), spec.name, spec.args...) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Start(); err != nil { + return nil, err + } + return cmd.Wait, nil +} + +func commandForURL(goos string, getenv func(string) string, lookPath func(string) (string, error), url string) (commandSpec, error) { + switch goos { + case "darwin": + return commandSpec{name: "open", args: []string{url}}, nil + case "windows": + return commandSpec{name: "rundll32", args: []string{"url.dll,FileProtocolHandler", url}}, nil + case "linux": + if isWSL(getenv) { + if path, err := lookPath("rundll32.exe"); err == nil { + return commandSpec{name: path, args: []string{"url.dll,FileProtocolHandler", url}}, nil + } + if path, err := lookPath("cmd.exe"); err == nil { + return commandSpec{name: path, args: []string{"/C", "start", "", quoteCmdArg(url)}}, nil + } + } + + for _, name := range []string{"xdg-open", "sensible-browser"} { + if path, err := lookPath(name); err == nil { + return commandSpec{name: path, args: []string{url}}, nil + } + } + } + + return commandSpec{}, errors.New("no browser opener found") +} + +func isWSL(getenv func(string) string) bool { + return getenv("WSL_DISTRO_NAME") != "" || getenv("WSL_INTEROP") != "" +} + +func quoteCmdArg(arg string) string { + quoted := `"` + for _, r := range arg { + switch r { + case '&', '|', '(', ')', '<', '>', '^': + quoted += "^" + case '"': + quoted += `\` + } + quoted += string(r) + } + return quoted + `"` +} diff --git a/internal/browser/open_test.go b/internal/browser/open_test.go new file mode 100644 index 0000000..a35f61b --- /dev/null +++ b/internal/browser/open_test.go @@ -0,0 +1,121 @@ +package browser + +import ( + "errors" + "reflect" + "testing" + "time" +) + +func TestCommandForURLUsesCmdExeOnWSL(t *testing.T) { + spec, err := commandForURL("linux", env(map[string]string{ + "WSL_DISTRO_NAME": "Ubuntu", + }), path(map[string]string{ + "cmd.exe": "/mnt/c/WINDOWS/system32/cmd.exe", + }), "https://example.test/device") + if err != nil { + t.Fatalf("commandForURL returned error: %v", err) + } + + want := commandSpec{name: "/mnt/c/WINDOWS/system32/cmd.exe", args: []string{"/C", "start", "", `"https://example.test/device"`}} + if !reflect.DeepEqual(spec, want) { + t.Fatalf("commandForURL() = %#v, want %#v", spec, want) + } +} + +func TestCommandForURLQuotesCmdExeURLOnWSLWhenURLContainsCmdMetacharacters(t *testing.T) { + spec, err := commandForURL("linux", env(map[string]string{ + "WSL_DISTRO_NAME": "Ubuntu", + }), path(map[string]string{ + "cmd.exe": "/mnt/c/WINDOWS/system32/cmd.exe", + }), "https://example.test/device?foo=1&bar=(2)|baz=a%26b") + if err != nil { + t.Fatalf("commandForURL returned error: %v", err) + } + + want := commandSpec{name: "/mnt/c/WINDOWS/system32/cmd.exe", args: []string{"/C", "start", "", `"https://example.test/device?foo=1^&bar=^(2^)^|baz=a%26b"`}} + if !reflect.DeepEqual(spec, want) { + t.Fatalf("commandForURL() = %#v, want %#v", spec, want) + } +} + +func TestCommandForURLUsesRundll32OnWSL(t *testing.T) { + spec, err := commandForURL("linux", env(map[string]string{ + "WSL_INTEROP": "/run/WSL/1_interop", + }), path(map[string]string{ + "rundll32.exe": "/mnt/c/WINDOWS/system32/rundll32.exe", + "cmd.exe": "/mnt/c/WINDOWS/system32/cmd.exe", + }), `https://example.test/device?code=A&B=(C)|D`) + if err != nil { + t.Fatalf("commandForURL returned error: %v", err) + } + + want := commandSpec{name: "/mnt/c/WINDOWS/system32/rundll32.exe", args: []string{"url.dll,FileProtocolHandler", `https://example.test/device?code=A&B=(C)|D`}} + if !reflect.DeepEqual(spec, want) { + t.Fatalf("commandForURL() = %#v, want %#v", spec, want) + } +} + +func TestCommandForURLUsesXDGOpenOnLinux(t *testing.T) { + spec, err := commandForURL("linux", env(nil), path(map[string]string{ + "xdg-open": "/usr/bin/xdg-open", + }), "https://example.test/device") + if err != nil { + t.Fatalf("commandForURL returned error: %v", err) + } + + want := commandSpec{name: "/usr/bin/xdg-open", args: []string{"https://example.test/device"}} + if !reflect.DeepEqual(spec, want) { + t.Fatalf("commandForURL() = %#v, want %#v", spec, want) + } +} + +func TestCommandForURLReturnsErrorWhenNoLinuxOpenerExists(t *testing.T) { + _, err := commandForURL("linux", env(nil), path(nil), "https://example.test/device") + if err == nil { + t.Fatal("commandForURL returned nil error") + } +} + +func TestLaunchCommandWaitsForStartedProcess(t *testing.T) { + waited := make(chan struct{}) + started := false + startProcess = func(commandSpec) (func() error, error) { + started = true + return func() error { + close(waited) + return nil + }, nil + } + t.Cleanup(func() { + startProcess = defaultStartProcess + }) + + if err := launchCommand(commandSpec{name: "opener", args: []string{"https://example.test/device"}}); err != nil { + t.Fatalf("launchCommand returned error: %v", err) + } + if !started { + t.Fatal("launchCommand did not start process") + } + + select { + case <-waited: + case <-time.After(time.Second): + t.Fatal("launchCommand did not wait for process") + } +} + +func env(values map[string]string) func(string) string { + return func(key string) string { + return values[key] + } +} + +func path(available map[string]string) func(string) (string, error) { + return func(name string) (string, error) { + if path, ok := available[name]; ok { + return path, nil + } + return "", errors.New("not found") + } +}