Skip to content

Commit f8adc60

Browse files
authored
Combine browser and auth-wait prompts in auth flow (#54)
1 parent 1cccd38 commit f8adc60

File tree

16 files changed

+548
-144
lines changed

16 files changed

+548
-144
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.26.0
44

55
require (
66
github.com/99designs/keyring v1.2.2
7+
github.com/charmbracelet/bubbles v1.0.0
78
github.com/charmbracelet/bubbletea v1.3.10
89
github.com/charmbracelet/lipgloss v1.1.0
910
github.com/charmbracelet/x/exp/teatest v0.0.0-20260216111343-536eb63c1f4c
@@ -27,7 +28,6 @@ require (
2728
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
2829
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
2930
github.com/cespare/xxhash/v2 v2.3.0 // indirect
30-
github.com/charmbracelet/bubbles v1.0.0 // indirect
3131
github.com/charmbracelet/colorprofile v0.4.2 // indirect
3232
github.com/charmbracelet/x/ansi v0.11.6 // indirect
3333
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF
2626
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
2727
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
2828
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
29-
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
30-
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
3129
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
3230
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
3331
github.com/charmbracelet/x/exp/teatest v0.0.0-20260216111343-536eb63c1f4c h1:/pbU92+xMwttewB4XK69/B9ISH0HMhOMrTIVhV4AS7M=

internal/auth/auth.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) {
4242
return "", fmt.Errorf("authentication required: set LOCALSTACK_AUTH_TOKEN or run in interactive mode")
4343
}
4444

45-
output.EmitInfo(a.sink, "No existing credentials found. Please log in:")
4645
token, err := a.login.Login(ctx)
4746
if err != nil {
47+
if errors.Is(err, context.Canceled) {
48+
return "", err
49+
}
4850
output.EmitWarning(a.sink, "Authentication failed.")
4951
return "", err
5052
}

internal/auth/login.go

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,46 +35,32 @@ func (l *loginProvider) Login(ctx context.Context) (string, error) {
3535
}
3636

3737
authURL := fmt.Sprintf("%s/auth/request/%s", getWebAppURL(), authReq.ID)
38-
output.EmitInfo(l.sink, fmt.Sprintf("Visit: %s", authURL))
39-
output.EmitInfo(l.sink, fmt.Sprintf("Verification code: %s", authReq.Code))
4038

41-
// Ask whether to open the browser; ENTER or Y accepts (default yes), N skips
42-
browserCh := make(chan output.InputResponse, 1)
43-
output.EmitUserInputRequest(l.sink, output.UserInputRequestEvent{
44-
Prompt: "Open browser now?",
45-
Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}},
46-
ResponseCh: browserCh,
39+
output.EmitAuth(l.sink, output.AuthEvent{
40+
Preamble: "Welcome to lstk, a command-line interface for LocalStack",
41+
Code: authReq.Code,
42+
URL: authURL,
4743
})
44+
_ = browser.OpenURL(authURL)
4845

49-
select {
50-
case resp := <-browserCh:
51-
if resp.Cancelled {
52-
return "", context.Canceled
53-
}
54-
if resp.SelectedKey != "n" {
55-
if err := browser.OpenURL(authURL); err != nil {
56-
output.EmitWarning(l.sink, fmt.Sprintf("Failed to open browser: %v", err))
57-
}
58-
}
59-
case <-ctx.Done():
60-
return "", ctx.Err()
61-
}
46+
output.EmitSpinnerStart(l.sink, "Waiting for authorization...")
6247

63-
// Wait for the user to complete authentication in the browser
64-
enterCh := make(chan output.InputResponse, 1)
48+
responseCh := make(chan output.InputResponse, 1)
6549
output.EmitUserInputRequest(l.sink, output.UserInputRequestEvent{
66-
Prompt: "Waiting for authentication",
67-
Options: []output.InputOption{{Key: "enter", Label: "Press ENTER when complete"}},
68-
ResponseCh: enterCh,
50+
Prompt: "Waiting for authorization...",
51+
Options: []output.InputOption{{Key: "any", Label: "Press any key when complete"}},
52+
ResponseCh: responseCh,
6953
})
7054

7155
select {
72-
case resp := <-enterCh:
56+
case resp := <-responseCh:
57+
output.EmitSpinnerStop(l.sink)
7358
if resp.Cancelled {
7459
return "", context.Canceled
7560
}
7661
return l.completeAuth(ctx, authReq)
7762
case <-ctx.Done():
63+
output.EmitSpinnerStop(l.sink)
7864
return "", ctx.Err()
7965
}
8066
}

internal/output/events.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,14 @@ type ErrorEvent struct {
4949
Actions []ErrorAction
5050
}
5151

52+
type AuthEvent struct {
53+
Preamble string
54+
Code string
55+
URL string
56+
}
57+
5258
type Event interface {
53-
MessageEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent
59+
MessageEvent | AuthEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent
5460
}
5561

5662
type Sink interface {
@@ -143,6 +149,10 @@ func EmitUserInputRequest(sink Sink, event UserInputRequestEvent) {
143149
Emit(sink, event)
144150
}
145151

152+
func EmitAuth(sink Sink, event AuthEvent) {
153+
Emit(sink, event)
154+
}
155+
146156
func EmitContainerLogLine(sink Sink, line string) {
147157
Emit(sink, ContainerLogLineEvent{Line: line})
148158
}

internal/output/events_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package output
2+
3+
import "testing"
4+
5+
type captureSink struct {
6+
events []any
7+
}
8+
9+
func (s *captureSink) emit(event any) {
10+
s.events = append(s.events, event)
11+
}
12+
13+
func TestEmitAuth(t *testing.T) {
14+
t.Parallel()
15+
16+
sink := &captureSink{}
17+
EmitAuth(sink, AuthEvent{
18+
Preamble: "Welcome",
19+
Code: "ABC123",
20+
URL: "https://example.com",
21+
})
22+
23+
if len(sink.events) != 1 {
24+
t.Fatalf("expected 1 event, got %d", len(sink.events))
25+
}
26+
event, ok := sink.events[0].(AuthEvent)
27+
if !ok {
28+
t.Fatalf("expected AuthEvent, got %T", sink.events[0])
29+
}
30+
if event.Code != "ABC123" {
31+
t.Fatalf("expected code %q, got %q", "ABC123", event.Code)
32+
}
33+
if event.URL != "https://example.com" {
34+
t.Fatalf("expected URL %q, got %q", "https://example.com", event.URL)
35+
}
36+
if event.Preamble != "Welcome" {
37+
t.Fatalf("expected preamble %q, got %q", "Welcome", event.Preamble)
38+
}
39+
40+
line, ok := FormatEventLine(event)
41+
if !ok {
42+
t.Fatal("expected formatter output")
43+
}
44+
if line != "Welcome\nYour one-time code: ABC123\nOpening browser to login...\nhttps://example.com" {
45+
t.Fatalf("unexpected formatted line: %q", line)
46+
}
47+
}

internal/output/plain_format.go

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ func FormatEventLine(event any) (string, bool) {
1010
switch e := event.(type) {
1111
case MessageEvent:
1212
return formatMessageEvent(e), true
13+
case AuthEvent:
14+
return formatAuthEvent(e), true
1315
case SpinnerEvent:
1416
if e.Active {
1517
return e.Text + "...", true
@@ -63,18 +65,55 @@ func formatProgressLine(e ProgressEvent) (string, bool) {
6365
}
6466

6567
func formatUserInputRequest(e UserInputRequestEvent) string {
66-
switch len(e.Options) {
68+
return FormatPrompt(e.Prompt, e.Options)
69+
}
70+
71+
// FormatPrompt formats a prompt string with its options into a display line.
72+
func FormatPrompt(prompt string, options []InputOption) string {
73+
lines := strings.Split(prompt, "\n")
74+
firstLine := lines[0]
75+
rest := lines[1:]
76+
labels := make([]string, 0, len(options))
77+
for _, opt := range options {
78+
if opt.Label != "" {
79+
labels = append(labels, opt.Label)
80+
}
81+
}
82+
83+
switch len(labels) {
6784
case 0:
68-
return e.Prompt
85+
if len(rest) == 0 {
86+
return firstLine
87+
}
88+
return strings.Join(append([]string{firstLine}, rest...), "\n")
6989
case 1:
70-
return fmt.Sprintf("%s (%s)", e.Prompt, e.Options[0].Label)
90+
firstLine = fmt.Sprintf("%s (%s)", firstLine, labels[0])
7191
default:
72-
labels := make([]string, len(e.Options))
73-
for i, opt := range e.Options {
74-
labels[i] = opt.Label
75-
}
76-
return fmt.Sprintf("%s [%s]", e.Prompt, strings.Join(labels, "/"))
92+
firstLine = fmt.Sprintf("%s [%s]", firstLine, strings.Join(labels, "/"))
93+
}
94+
95+
if len(rest) == 0 {
96+
return firstLine
97+
}
98+
return strings.Join(append([]string{firstLine}, rest...), "\n")
99+
}
100+
101+
func formatAuthEvent(e AuthEvent) string {
102+
var sb strings.Builder
103+
if e.Preamble != "" {
104+
sb.WriteString(e.Preamble)
105+
sb.WriteString("\n")
106+
}
107+
if e.Code != "" {
108+
sb.WriteString("Your one-time code: ")
109+
sb.WriteString(e.Code)
110+
sb.WriteString("\n")
111+
}
112+
if e.URL != "" {
113+
sb.WriteString("Opening browser to login...\n")
114+
sb.WriteString(e.URL)
77115
}
116+
return strings.TrimRight(sb.String(), "\n")
78117
}
79118

80119
func formatMessageEvent(e MessageEvent) string {

internal/output/plain_format_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ func TestFormatEventLine(t *testing.T) {
3535
want: "> Warning: careful",
3636
wantOK: true,
3737
},
38+
{
39+
name: "instructions event full",
40+
event: AuthEvent{Preamble: "Welcome", Code: "ABC123", URL: "https://example.com"},
41+
want: "Welcome\nYour one-time code: ABC123\nOpening browser to login...\nhttps://example.com",
42+
wantOK: true,
43+
},
44+
{
45+
name: "instructions event code only",
46+
event: AuthEvent{Code: "XYZ"},
47+
want: "Your one-time code: XYZ",
48+
wantOK: true,
49+
},
3850
{
3951
name: "status pulling",
4052
event: ContainerStatusEvent{Phase: "pulling", Container: "localstack/localstack:latest"},

internal/output/plain_sink_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ func TestPlainSink_UsesFormatterParity(t *testing.T) {
219219
MessageEvent{Severity: SeverityWarning, Text: "careful"},
220220
MessageEvent{Severity: SeveritySuccess, Text: "done"},
221221
MessageEvent{Severity: SeverityNote, Text: "fyi"},
222+
AuthEvent{Code: "ABC123", URL: "https://example.com"},
222223
SpinnerEvent{Active: true, Text: "Loading"},
223224
ErrorEvent{Title: "Failed", Summary: "Something broke"},
224225
ContainerStatusEvent{Phase: "starting", Container: "localstack"},
@@ -232,6 +233,8 @@ func TestPlainSink_UsesFormatterParity(t *testing.T) {
232233
switch e := event.(type) {
233234
case MessageEvent:
234235
Emit(sink, e)
236+
case AuthEvent:
237+
Emit(sink, e)
235238
case SpinnerEvent:
236239
Emit(sink, e)
237240
case ErrorEvent:

0 commit comments

Comments
 (0)