Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion command/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"golang.org/x/oauth2"
"golang.org/x/term"
)

func init() {
Expand Down Expand Up @@ -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;;<url> ESC \ <text> 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
}

Expand Down
40 changes: 40 additions & 0 deletions command/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package command
import (
"context"
"net"
"regexp"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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")
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading