diff --git a/VERSION b/VERSION index c946ee6..1180819 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.6 +0.1.7 diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index fd0d135..9bef68d 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -117,7 +117,7 @@ func main() { OnAuthURL: func(u string) { fmt.Fprintln(os.Stderr, "gitea-mcp: opening browser for Gitea OAuth login:") fmt.Fprintln(os.Stderr, " ", u) - if err := browser.Open(u); err != nil { + if err := browser.OpenWith(svc.OAuthBrowser, u); err != nil { fmt.Fprintln(os.Stderr, "gitea-mcp: open browser:", err) fmt.Fprintln(os.Stderr, "(visit the URL above manually)") } diff --git a/cmd/grafana-mcp/main.go b/cmd/grafana-mcp/main.go index 00392ab..fca3278 100644 --- a/cmd/grafana-mcp/main.go +++ b/cmd/grafana-mcp/main.go @@ -114,7 +114,7 @@ func main() { OnAuthURL: func(u string) { fmt.Fprintln(os.Stderr, "grafana-mcp: opening browser for Grafana OAuth login:") fmt.Fprintln(os.Stderr, " ", u) - if err := browser.Open(u); err != nil { + if err := browser.OpenWith(svc.OAuthBrowser, u); err != nil { fmt.Fprintln(os.Stderr, "grafana-mcp: open browser:", err) fmt.Fprintln(os.Stderr, "(visit the URL above manually)") } diff --git a/cmd/gssh-mcp/main.go b/cmd/gssh-mcp/main.go index 42784f3..935ab9f 100644 --- a/cmd/gssh-mcp/main.go +++ b/cmd/gssh-mcp/main.go @@ -101,7 +101,7 @@ func main() { OnAuthURL: func(u string) { fmt.Fprintln(os.Stderr, "gssh-mcp: opening browser for Gssh OAuth login:") fmt.Fprintln(os.Stderr, " ", u) - if err := browser.Open(u); err != nil { + if err := browser.OpenWith(svc.OAuthBrowser, u); err != nil { fmt.Fprintln(os.Stderr, "gssh-mcp: open browser:", err) fmt.Fprintln(os.Stderr, "(visit the URL above manually)") } diff --git a/cmd/searxng-mcp/main.go b/cmd/searxng-mcp/main.go index 41e47f4..20e7c71 100644 --- a/cmd/searxng-mcp/main.go +++ b/cmd/searxng-mcp/main.go @@ -95,7 +95,7 @@ func main() { OnAuthURL: func(u string) { fmt.Fprintln(os.Stderr, "searxng-mcp: opening browser for SearXNG OAuth login:") fmt.Fprintln(os.Stderr, " ", u) - if err := browser.Open(u); err != nil { + if err := browser.OpenWith(svc.OAuthBrowser, u); err != nil { fmt.Fprintln(os.Stderr, "searxng-mcp: open browser:", err) fmt.Fprintln(os.Stderr, "(visit the URL above manually)") } diff --git a/config.example.toml b/config.example.toml index 9bda31c..d26a659 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,5 +1,11 @@ # sherlock conifugration - lives in $XDG_CONFIG_HOME/sherlock/config.toml +# ── OAuth login ─────────────────────────────────────────────────────── +# Optional browser executable/name for OAuth login URLs. Leave unset to +# use the OS default browser (xdg-open/open/rundll32). +# [oauth] +# browser = "firefox" + # ── Providers ──────────────────────────────────────────────────────── # A [providers.] block is a reusable OAuth/OIDC client. Services # point at one via `provider = ""`. Define a provider once and diff --git a/docs/configuration.md b/docs/configuration.md index 61a3259..20b39cb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,6 +19,10 @@ Resolved in order: ## Shape +`[oauth]` is optional. Set `browser = ""` to open OAuth login URLs +with a specific browser, for example `browser = "firefox"`. Leave it unset to +use the OS default browser. + `[providers.]` defines a reusable OAuth/OIDC identity: `issuer`, `client_id`, optional `client_secret`. diff --git a/internal/browser/browser.go b/internal/browser/browser.go index 9d65def..7869be8 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -7,6 +7,7 @@ package browser import ( "os/exec" "runtime" + "strings" ) // Open starts the OS's default browser pointed at url, then returns @@ -15,9 +16,29 @@ import ( // itself reports — there's no portable way to know whether the user // actually loaded the page. func Open(url string) error { + return OpenWith("", url) +} + +// OpenWith starts browser pointed at url, then returns immediately. If +// browser is empty, it falls back to the OS default helper used by Open. +func OpenWith(browser, url string) error { + bin, args := commandFor(browser, url, runtime.GOOS) + cmd := exec.Command(bin, args...) + if err := cmd.Start(); err != nil { + return err + } + go func() { _ = cmd.Wait() }() + return nil +} + +func commandFor(browser, url, goos string) (string, []string) { + if browser = strings.TrimSpace(browser); browser != "" { + return browser, []string{url} + } + var bin string var args []string - switch runtime.GOOS { + switch goos { case "darwin": bin = "open" args = []string{url} @@ -28,10 +49,5 @@ func Open(url string) error { bin = "xdg-open" args = []string{url} } - cmd := exec.Command(bin, args...) - if err := cmd.Start(); err != nil { - return err - } - go func() { _ = cmd.Wait() }() - return nil + return bin, args } diff --git a/internal/browser/browser_test.go b/internal/browser/browser_test.go new file mode 100644 index 0000000..ef09417 --- /dev/null +++ b/internal/browser/browser_test.go @@ -0,0 +1,36 @@ +package browser + +import ( + "reflect" + "testing" +) + +func TestCommandFor_Defaults(t *testing.T) { + tests := []struct { + name string + goos string + wantBin string + wantArgs []string + }{ + {name: "linux", goos: "linux", wantBin: "xdg-open", wantArgs: []string{"https://example.com"}}, + {name: "darwin", goos: "darwin", wantBin: "open", wantArgs: []string{"https://example.com"}}, + {name: "windows", goos: "windows", wantBin: "rundll32", wantArgs: []string{"url.dll,FileProtocolHandler", "https://example.com"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotBin, gotArgs := commandFor("", "https://example.com", tt.goos) + if gotBin != tt.wantBin || !reflect.DeepEqual(gotArgs, tt.wantArgs) { + t.Fatalf("commandFor = %q %v, want %q %v", gotBin, gotArgs, tt.wantBin, tt.wantArgs) + } + }) + } +} + +func TestCommandFor_CustomBrowser(t *testing.T) { + gotBin, gotArgs := commandFor(" firefox ", "https://example.com", "linux") + wantArgs := []string{"https://example.com"} + if gotBin != "firefox" || !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("commandFor = %q %v, want firefox %v", gotBin, gotArgs, wantArgs) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index fb48edb..1e5e822 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,9 @@ // // Shape: // +// [oauth] +// browser = "firefox" # optional; default OS browser when empty +// // [providers.sherlock-cli] # a reusable OAuth provider // issuer = "https://id.example/application/o/sherlock-cli/" // client_id = "abc123" @@ -66,8 +69,16 @@ type Service struct { BaseURL string `toml:"base_url"` } +// OAuth contains global OAuth-flow preferences. +type OAuth struct { + // Browser is an optional executable name or path used to open OAuth + // login URLs. Empty means the OS default browser. + Browser string `toml:"browser"` +} + // Config is the whole parsed file. type Config struct { + OAuth OAuth `toml:"oauth"` Providers map[string]Provider `toml:"providers"` Services map[string]Service `toml:"services"` @@ -83,6 +94,7 @@ type Resolved struct { ClientID string ClientSecret string BaseURL string + OAuthBrowser string } // DefaultPath returns the config-file path sherlock reads, honouring @@ -155,6 +167,7 @@ func (c *Config) Service(name string) (Resolved, error) { ClientID: svc.ClientID, ClientSecret: svc.ClientSecret, BaseURL: svc.BaseURL, + OAuthBrowser: c.OAuth.Browser, } if svc.Provider != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c43815f..8514899 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -63,6 +63,26 @@ base_url = "https://gitea.example" } } +func TestService_OAuthBrowser(t *testing.T) { + path := writeConfig(t, ` +[oauth] +browser = "firefox" + +[services.gitea] +issuer = "https://gitea.example" +client_id = "gitea-client" +base_url = "https://gitea.example" +`) + c, _ := LoadFrom(path) + got, err := c.Service("gitea") + if err != nil { + t.Fatalf("Service: %v", err) + } + if got.OAuthBrowser != "firefox" { + t.Fatalf("OAuthBrowser = %q", got.OAuthBrowser) + } +} + func TestService_UnknownService(t *testing.T) { path := writeConfig(t, ` [services.gitea]