diff --git a/command/login.go b/command/login.go index a70f61ce..42aa62a5 100644 --- a/command/login.go +++ b/command/login.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" "golang.org/x/oauth2" + "golang.org/x/term" ) func init() { @@ -160,8 +161,25 @@ func printURLToConsole(url string) error { return nil } +// hyperlink wraps text in a terminal hyperlink pointing at url, so supporting +// terminals (including tmux) render it as a clickable link. When stdout is not a +// terminal, the plain text is returned unchanged so piped or redirected output is +// never corrupted by escape sequences. +func hyperlink(url, text string) string { + if !term.IsTerminal(int(os.Stdout.Fd())) { + return text + } + return osc8Hyperlink(url, text) +} + +// osc8Hyperlink formats an OSC 8 terminal hyperlink escape sequence: +// ESC ]8;; ESC \ ESC ]8;; ESC \ +func osc8Hyperlink(url, text string) string { + return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, text) +} + func friendlyPrintURLToConsole(url string) error { - fmt.Printf("Visit the following link in your terminal: %s\n", url) + fmt.Printf("Visit the following link in your terminal: %s\n", hyperlink(url, url)) return nil } diff --git a/command/login_test.go b/command/login_test.go index f8ee663b..a207ab88 100644 --- a/command/login_test.go +++ b/command/login_test.go @@ -3,6 +3,8 @@ package command import ( "context" "net" + "regexp" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -56,3 +58,41 @@ func Test_findFirstFreePort_RejectsIfAllProvidedPortsExhausted(t *testing.T) { _, err := findFirstFreePort(context.Background(), "127.0.0.1", activePorts) assert.ErrorIs(t, err, errNoPortsAvailable) } + +func Test_osc8Hyperlink_ProducesExactOSC8Sequence(t *testing.T) { + url := "https://example.okta.com/oauth2/v1/authorize?client_id=abc&scope=openid" + got := osc8Hyperlink(url, url) + want := "\x1b]8;;" + url + "\x1b\\" + url + "\x1b]8;;\x1b\\" + assert.Equal(t, want, got) + assert.True(t, strings.HasPrefix(got, "\x1b]8;;")) + assert.True(t, strings.HasSuffix(got, "\x1b]8;;\x1b\\")) +} + +func Test_osc8Hyperlink_DegradesToPlainText(t *testing.T) { + url := "https://example.okta.com/authorize?x=1&y=2" + got := osc8Hyperlink(url, url) + // An OSC-8-aware terminal that chooses not to render the link still + // consumes the OSC control sequence up to its ST terminator, leaving + // only the link text visible. + osc8 := regexp.MustCompile("\x1b\\]8;;[^\x1b]*\x1b\\\\") + visible := osc8.ReplaceAllString(got, "") + assert.Equal(t, url, visible) +} + +func Test_osc8Hyperlink_RawOutputContainsFullURL(t *testing.T) { + url := "https://example.okta.com/authorize?x=1&y=2" + got := osc8Hyperlink(url, url) + // Even if a terminal renders escapes literally, the URL text between + // the OSC wrappers is emitted verbatim and contiguous. + assert.Contains(t, got, "\x1b\\"+url+"\x1b]8;;") +} + +func Test_hyperlink_ReturnsPlainTextWhenNotATerminal(t *testing.T) { + // Under `go test`, stdout is not a terminal, so hyperlink must return the + // plain text unchanged — no OSC 8 escape bytes that would corrupt piped or + // redirected output. + url := "https://example.okta.com/authorize?x=1&y=2" + got := hyperlink(url, url) + assert.Equal(t, url, got) + assert.NotContains(t, got, "\x1b") +} diff --git a/go.mod b/go.mod index bf0d50a3..4df43487 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/zalando/go-keyring v0.2.6 golang.org/x/net v0.25.0 golang.org/x/oauth2 v0.26.0 + golang.org/x/term v0.20.0 ) require ( diff --git a/go.sum b/go.sum index 4752c84b..9c0beaeb 100644 --- a/go.sum +++ b/go.sum @@ -119,6 +119,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=