66 "crypto/rand"
77 "encoding/hex"
88 "fmt"
9+ "io"
910 "net"
1011 "net/http"
1112 "os"
@@ -23,6 +24,18 @@ import (
2324
2425const dashboardBase = "https://dashboard.supermodeltools.com"
2526
27+ // loginOut is the writer used for all Login output. Override in tests to
28+ // capture output without touching os.Stdout.
29+ var loginOut io.Writer = os .Stdout
30+
31+ // stdinReader is the reader used by readSecret in non-TTY mode. Override in
32+ // tests to supply canned input without touching os.Stdin.
33+ var stdinReader io.Reader = os .Stdin
34+
35+ // openBrowserFunc is the injectable browser-open function. Override in tests
36+ // to simulate headless environments where a browser cannot be launched.
37+ var openBrowserFunc = openBrowserDefault
38+
2639// Login runs the browser-based login flow. Opens the dashboard to create an
2740// API key, receives it via localhost callback, validates, and saves it.
2841// Falls back to manual paste if the browser flow fails.
@@ -35,8 +48,8 @@ func Login(ctx context.Context) error {
3548 // Start localhost server on a random port.
3649 listener , err := net .Listen ("tcp" , "127.0.0.1:0" )
3750 if err != nil {
38- fmt .Fprintln (os . Stderr , "Could not start local server — falling back to manual login." )
39- return loginManual (cfg )
51+ fmt .Fprintln (loginOut , "Could not start local server — falling back to manual login." )
52+ return loginManual (cfg , "" )
4053 }
4154 port := listener .Addr ().(* net.TCPAddr ).Port
4255 state := randomState ()
@@ -71,36 +84,36 @@ func Login(ctx context.Context) error {
7184
7285 // Build the dashboard URL and open the browser.
7386 authURL := fmt .Sprintf ("%s/cli-auth?port=%d&state=%s" , dashboardBase , port , state )
74- fmt .Println ( "Opening browser to log in..." )
75- fmt .Printf ( "If the browser doesn't open, visit:\n %s\n \n " , authURL )
87+ fmt .Fprintln ( loginOut , "Opening browser to log in..." )
88+ fmt .Fprintf ( loginOut , "If the browser doesn't open, visit:\n %s\n \n " , authURL )
7689
77- if err := openBrowser (authURL ); err != nil {
78- fmt .Fprintln (os . Stderr , "Could not open browser — falling back to manual login." )
90+ if err := openBrowserFunc (authURL ); err != nil {
91+ fmt .Fprintln (loginOut , "Could not open browser — falling back to manual login." )
7992 srv .Close ()
80- return loginManual (cfg )
93+ return loginManual (cfg , authURL )
8194 }
8295
8396 // Wait for callback or timeout.
84- fmt .Print ( "Waiting for authentication..." )
97+ fmt .Fprint ( loginOut , "Waiting for authentication..." )
8598 select {
8699 case key := <- keyCh :
87- fmt .Println ( )
100+ fmt .Fprintln ( loginOut )
88101 cfg .APIKey = strings .TrimSpace (key )
89102 if err := cfg .Save (); err != nil {
90103 return err
91104 }
92105 ui .Success ("Authenticated — key saved to %s" , config .Path ())
93106 return nil
94107 case err := <- errCh :
95- fmt .Println ( )
108+ fmt .Fprintln ( loginOut )
96109 return fmt .Errorf ("local server error: %w" , err )
97110 case <- time .After (5 * time .Minute ):
98- fmt .Println ( )
99- fmt .Fprintln (os . Stderr , "Timed out waiting for browser login — falling back to manual login." )
111+ fmt .Fprintln ( loginOut )
112+ fmt .Fprintln (loginOut , "Timed out waiting for browser login — falling back to manual login." )
100113 srv .Close ()
101- return loginManual (cfg )
114+ return loginManual (cfg , authURL )
102115 case <- ctx .Done ():
103- fmt .Println ( )
116+ fmt .Fprintln ( loginOut )
104117 return ctx .Err ()
105118 }
106119}
@@ -141,10 +154,16 @@ func Logout(_ context.Context) error {
141154 return nil
142155}
143156
144- // loginManual is the fallback paste-based login.
145- func loginManual (cfg * config.Config ) error {
146- fmt .Println ("Get your API key at https://dashboard.supermodeltools.com/api-keys" )
147- fmt .Print ("Paste your API key: " )
157+ // loginManual is the fallback paste-based login. When authURL is non-empty
158+ // (i.e. the browser-open step failed), it is printed so the user can visit it
159+ // from another machine or browser.
160+ func loginManual (cfg * config.Config , authURL string ) error {
161+ if authURL != "" {
162+ fmt .Fprintf (loginOut , "Visit the following URL to get your API key:\n %s\n \n " , authURL )
163+ } else {
164+ fmt .Fprintf (loginOut , "Get your API key at %s/api-keys\n " , dashboardBase )
165+ }
166+ fmt .Fprint (loginOut , "Paste your API key: " )
148167
149168 key , err := readSecret ()
150169 if err != nil {
@@ -163,7 +182,7 @@ func loginManual(cfg *config.Config) error {
163182 return nil
164183}
165184
166- func openBrowser (url string ) error {
185+ func openBrowserDefault (url string ) error {
167186 switch runtime .GOOS {
168187 case "darwin" :
169188 return exec .Command ("open" , url ).Start ()
@@ -183,17 +202,18 @@ func randomState() string {
183202}
184203
185204// readSecret reads a line from stdin, suppressing echo when a TTY is attached.
205+ // In non-TTY mode it reads from stdinReader (injectable for tests).
186206func readSecret () (string , error ) {
187207 fd := int (syscall .Stdin ) //nolint:unconvert // syscall.Stdin is uintptr on Windows
188208 if term .IsTerminal (fd ) {
189209 b , err := term .ReadPassword (fd )
190- fmt .Println ( )
210+ fmt .Fprintln ( loginOut )
191211 if err != nil {
192212 return "" , err
193213 }
194214 return string (b ), nil
195215 }
196- scanner := bufio .NewScanner (os . Stdin )
216+ scanner := bufio .NewScanner (stdinReader )
197217 if scanner .Scan () {
198218 return scanner .Text (), nil
199219 }
0 commit comments