Skip to content

Commit a90ffcb

Browse files
committed
feat(cli): always show login URL during auth login with copy support
When the browser opens during `auth login`, the login URL is now always displayed so users can copy or navigate to it manually. Pressing 'c' copies the URL to the system clipboard. Closes #2990 Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent 62867c0 commit a90ffcb

2 files changed

Lines changed: 122 additions & 4 deletions

File tree

app/cli/cmd/auth_login.go

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -23,6 +23,7 @@ import (
2323
"net"
2424
"net/http"
2525
"net/url"
26+
"os"
2627
"os/exec"
2728
"runtime"
2829
"time"
@@ -32,6 +33,7 @@ import (
3233
jwt "github.com/golang-jwt/jwt/v4"
3334
"github.com/spf13/cobra"
3435
"github.com/spf13/viper"
36+
"golang.org/x/term"
3537
)
3638

3739
type app struct {
@@ -87,11 +89,25 @@ func interactiveAuth(forceHeadless bool) error {
8789

8890
a.serverChan = make(chan error)
8991

90-
// Run server in background
92+
loginURLStr := serverLoginURL.String()
93+
fmt.Println()
94+
fmt.Println(" Browser didn't open? Use the URL below to sign in (press c to copy):")
95+
fmt.Println()
96+
fmt.Printf(" %s\n", loginURLStr)
97+
fmt.Println()
98+
fmt.Println(" Waiting for authentication to complete...")
99+
100+
// Save terminal state before raw mode so we can restore it reliably.
101+
// The keypress goroutine may be blocked on stdin.Read when auth completes,
102+
// so its deferred restore won't run until the process exits.
103+
fd := int(os.Stdin.Fd())
104+
termState, _ := term.GetState(fd)
105+
106+
done := make(chan struct{})
107+
go listenForCopyKeypress(loginURLStr, done)
108+
91109
http.HandleFunc(callbackURL.Path, a.handleCallback)
92110
go func() {
93-
logger.Info().Msg("waiting for the authentication to be completed, please check your browser")
94-
95111
server := &http.Server{ReadHeaderTimeout: time.Second}
96112

97113
err := server.Serve(listener)
@@ -101,6 +117,10 @@ func interactiveAuth(forceHeadless bool) error {
101117
}()
102118

103119
err = <-a.serverChan
120+
close(done)
121+
if termState != nil {
122+
term.Restore(fd, termState)
123+
}
104124
if err != nil {
105125
return err
106126
}
@@ -206,3 +226,47 @@ func localListenerAndCallbackURL() (net.Listener, *url.URL, error) {
206226
callbackURL := &url.URL{Scheme: "http", Host: listener.Addr().String(), Path: "/auth/callback"}
207227
return listener, callbackURL, nil
208228
}
229+
230+
const ctrlC = 3
231+
232+
// listenForCopyKeypress puts the terminal in raw mode and listens for
233+
// 'c' keypress to copy the URL to the system clipboard.
234+
func listenForCopyKeypress(urlStr string, done <-chan struct{}) {
235+
fd := int(os.Stdin.Fd())
236+
237+
oldState, err := term.MakeRaw(fd)
238+
if err != nil {
239+
return
240+
}
241+
defer term.Restore(fd, oldState)
242+
243+
buf := make([]byte, 1)
244+
for {
245+
select {
246+
case <-done:
247+
return
248+
default:
249+
}
250+
251+
n, err := os.Stdin.Read(buf)
252+
if err != nil || n == 0 {
253+
return
254+
}
255+
256+
switch buf[0] {
257+
case 'c', 'C':
258+
term.Restore(fd, oldState)
259+
if err := copyToClipboard(urlStr); err != nil {
260+
fmt.Println(" Could not copy to clipboard. Please copy the URL manually.")
261+
} else {
262+
fmt.Println(" Copied to clipboard!")
263+
}
264+
oldState, err = term.MakeRaw(fd)
265+
if err != nil {
266+
return
267+
}
268+
case ctrlC:
269+
return
270+
}
271+
}
272+
}

app/cli/cmd/clipboard.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package cmd
17+
18+
import (
19+
"fmt"
20+
"os/exec"
21+
"runtime"
22+
"strings"
23+
)
24+
25+
func copyToClipboard(text string) error {
26+
var name string
27+
var args []string
28+
29+
switch runtime.GOOS {
30+
case "darwin":
31+
name = "pbcopy"
32+
case "windows":
33+
name = "clip"
34+
default:
35+
// Linux: try xclip first, then xsel
36+
for _, candidate := range [][]string{
37+
{"xclip", "-selection", "clipboard"},
38+
{"xsel", "--clipboard", "--input"},
39+
} {
40+
if _, err := exec.LookPath(candidate[0]); err == nil {
41+
name = candidate[0]
42+
args = candidate[1:]
43+
break
44+
}
45+
}
46+
if name == "" {
47+
return fmt.Errorf("no clipboard tool available (install xclip or xsel)")
48+
}
49+
}
50+
51+
cmd := exec.Command(name, args...)
52+
cmd.Stdin = strings.NewReader(text)
53+
return cmd.Run()
54+
}

0 commit comments

Comments
 (0)