diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 3f187c2..0dbc286 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "io" "net" "net/http" "os" @@ -23,6 +24,18 @@ import ( const dashboardBase = "https://dashboard.supermodeltools.com" +// loginOut is the writer used for all Login output. Override in tests to +// capture output without touching os.Stdout. +var loginOut io.Writer = os.Stdout + +// stdinReader is the reader used by readSecret in non-TTY mode. Override in +// tests to supply canned input without touching os.Stdin. +var stdinReader io.Reader = os.Stdin + +// openBrowserFunc is the injectable browser-open function. Override in tests +// to simulate headless environments where a browser cannot be launched. +var openBrowserFunc = openBrowserDefault + // Login runs the browser-based login flow. Opens the dashboard to create an // API key, receives it via localhost callback, validates, and saves it. // Falls back to manual paste if the browser flow fails. @@ -35,8 +48,8 @@ func Login(ctx context.Context) error { // Start localhost server on a random port. listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { - fmt.Fprintln(os.Stderr, "Could not start local server — falling back to manual login.") - return loginManual(cfg) + fmt.Fprintln(loginOut, "Could not start local server — falling back to manual login.") + return loginManual(cfg, "") } port := listener.Addr().(*net.TCPAddr).Port state := randomState() @@ -71,20 +84,20 @@ func Login(ctx context.Context) error { // Build the dashboard URL and open the browser. authURL := fmt.Sprintf("%s/cli-auth?port=%d&state=%s", dashboardBase, port, state) - fmt.Println("Opening browser to log in...") - fmt.Printf("If the browser doesn't open, visit:\n %s\n\n", authURL) + fmt.Fprintln(loginOut, "Opening browser to log in...") + fmt.Fprintf(loginOut, "If the browser doesn't open, visit:\n %s\n\n", authURL) - if err := openBrowser(authURL); err != nil { - fmt.Fprintln(os.Stderr, "Could not open browser — falling back to manual login.") + if err := openBrowserFunc(authURL); err != nil { + fmt.Fprintln(loginOut, "Could not open browser — falling back to manual login.") srv.Close() - return loginManual(cfg) + return loginManual(cfg, authURL) } // Wait for callback or timeout. - fmt.Print("Waiting for authentication...") + fmt.Fprint(loginOut, "Waiting for authentication...") select { case key := <-keyCh: - fmt.Println() + fmt.Fprintln(loginOut) cfg.APIKey = strings.TrimSpace(key) if err := cfg.Save(); err != nil { return err @@ -92,15 +105,15 @@ func Login(ctx context.Context) error { ui.Success("Authenticated — key saved to %s", config.Path()) return nil case err := <-errCh: - fmt.Println() + fmt.Fprintln(loginOut) return fmt.Errorf("local server error: %w", err) case <-time.After(5 * time.Minute): - fmt.Println() - fmt.Fprintln(os.Stderr, "Timed out waiting for browser login — falling back to manual login.") + fmt.Fprintln(loginOut) + fmt.Fprintln(loginOut, "Timed out waiting for browser login — falling back to manual login.") srv.Close() - return loginManual(cfg) + return loginManual(cfg, authURL) case <-ctx.Done(): - fmt.Println() + fmt.Fprintln(loginOut) return ctx.Err() } } @@ -141,10 +154,16 @@ func Logout(_ context.Context) error { return nil } -// loginManual is the fallback paste-based login. -func loginManual(cfg *config.Config) error { - fmt.Println("Get your API key at https://dashboard.supermodeltools.com/api-keys") - fmt.Print("Paste your API key: ") +// loginManual is the fallback paste-based login. When authURL is non-empty +// (i.e. the browser-open step failed), it is printed so the user can visit it +// from another machine or browser. +func loginManual(cfg *config.Config, authURL string) error { + if authURL != "" { + fmt.Fprintf(loginOut, "Visit the following URL to get your API key:\n %s\n\n", authURL) + } else { + fmt.Fprintf(loginOut, "Get your API key at %s/api-keys\n", dashboardBase) + } + fmt.Fprint(loginOut, "Paste your API key: ") key, err := readSecret() if err != nil { @@ -163,7 +182,7 @@ func loginManual(cfg *config.Config) error { return nil } -func openBrowser(url string) error { +func openBrowserDefault(url string) error { switch runtime.GOOS { case "darwin": return exec.Command("open", url).Start() @@ -183,17 +202,18 @@ func randomState() string { } // readSecret reads a line from stdin, suppressing echo when a TTY is attached. +// In non-TTY mode it reads from stdinReader (injectable for tests). func readSecret() (string, error) { fd := int(syscall.Stdin) //nolint:unconvert // syscall.Stdin is uintptr on Windows if term.IsTerminal(fd) { b, err := term.ReadPassword(fd) - fmt.Println() + fmt.Fprintln(loginOut) if err != nil { return "", err } return string(b), nil } - scanner := bufio.NewScanner(os.Stdin) + scanner := bufio.NewScanner(stdinReader) if scanner.Scan() { return scanner.Text(), nil } diff --git a/internal/auth/handler_test.go b/internal/auth/handler_test.go index 533e150..eac9123 100644 --- a/internal/auth/handler_test.go +++ b/internal/auth/handler_test.go @@ -1,6 +1,7 @@ package auth import ( + "bytes" "context" "fmt" "net" @@ -8,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -284,3 +286,59 @@ func TestLogout_SaveError(t *testing.T) { t.Error("expected error when cfg.Save fails during logout") } } + +// TestLoginFallback_HeadlessBrowser verifies that when the browser cannot be +// opened (headless/SSH/container environments), Login prints the auth URL to +// stdout and falls back to prompting the user to paste an API key manually. +func TestLoginFallback_HeadlessBrowser(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("SUPERMODEL_API_KEY", "") + + // Override the injectable browser-open function to simulate headless failure. + orig := openBrowserFunc + openBrowserFunc = func(url string) error { + return fmt.Errorf("no display available") + } + t.Cleanup(func() { openBrowserFunc = orig }) + + // Provide stdin replacement so loginManual can read the pasted key. + stdinInput := "smsk_live_headless_test\n" + origStdinReader := stdinReader + stdinReader = strings.NewReader(stdinInput) + t.Cleanup(func() { stdinReader = origStdinReader }) + + // Capture output to verify the auth URL was printed. + var outBuf bytes.Buffer + origOut := loginOut + loginOut = &outBuf + t.Cleanup(func() { loginOut = origOut }) + + ctx := context.Background() + if err := Login(ctx); err != nil { + t.Fatalf("Login returned unexpected error: %v", err) + } + + output := outBuf.String() + + // The auth URL (with port and state) must appear in the output so the user + // can visit it in a separate browser. + if !strings.Contains(output, dashboardBase+"/cli-auth") { + t.Errorf("expected auth URL containing %q in output, got:\n%s", dashboardBase+"/cli-auth", output) + } + + // A prompt telling the user to paste their API key must appear. + if !strings.Contains(output, "Paste your API key") { + t.Errorf("expected 'Paste your API key' prompt in output, got:\n%s", output) + } + + // The API key must have been saved. + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + if cfg.APIKey != "smsk_live_headless_test" { + t.Errorf("expected API key %q saved, got %q", "smsk_live_headless_test", cfg.APIKey) + } +}