Skip to content

Commit ec14f45

Browse files
gtsiolisclaude
andauthored
Prompt for login before emulator selection on first run (#270)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent eebd150 commit ec14f45

2 files changed

Lines changed: 87 additions & 0 deletions

File tree

internal/ui/run.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77

88
tea "github.com/charmbracelet/bubbletea"
9+
"github.com/localstack/lstk/internal/auth"
910
"github.com/localstack/lstk/internal/container"
1011
"github.com/localstack/lstk/internal/output"
1112
"github.com/localstack/lstk/internal/runtime"
@@ -75,6 +76,19 @@ func Run(parentCtx context.Context, runOpts RunOptions) error {
7576
p.Send(runDoneMsg{})
7677
return
7778
}
79+
// Resolve the auth token before any emulator-selection prompt so the user
80+
// logs in first and only configures an emulator once they're authenticated.
81+
// container.Start still calls GetToken as a safety net for non-interactive
82+
// callers; once the token is in opts.AuthToken (or the keyring), it returns
83+
// immediately.
84+
if authErr := resolveAuthToken(ctx, sink, &runOpts); authErr != nil {
85+
if errors.Is(authErr, context.Canceled) {
86+
return
87+
}
88+
err = authErr
89+
p.Send(runErrMsg{err: authErr})
90+
return
91+
}
7892
if runOpts.NeedsEmulatorSelection {
7993
newContainers, selErr := container.SelectEmulator(ctx, sink, runOpts.ConfigPath)
8094
if selErr != nil {
@@ -115,6 +129,23 @@ func Run(parentCtx context.Context, runOpts RunOptions) error {
115129
return nil
116130
}
117131

132+
// resolveAuthToken ensures the user is authenticated before the start flow
133+
// continues. On success, the resolved token is written to opts.StartOptions.AuthToken
134+
// so container.Start short-circuits its own auth call.
135+
func resolveAuthToken(ctx context.Context, sink output.Sink, opts *RunOptions) error {
136+
tokenStorage, err := auth.NewTokenStorage(opts.StartOptions.ForceFileKeyring, opts.StartOptions.Logger)
137+
if err != nil {
138+
return err
139+
}
140+
a := auth.New(sink, opts.StartOptions.PlatformClient, tokenStorage, opts.StartOptions.AuthToken, opts.StartOptions.WebAppURL, true, "")
141+
token, err := a.GetToken(ctx)
142+
if err != nil {
143+
return err
144+
}
145+
opts.StartOptions.AuthToken = token
146+
return nil
147+
}
148+
118149
func RunMessage(parentCtx context.Context, event output.MessageEvent) error {
119150
return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error {
120151
sink.Emit(event)

test/integration/emulator_select_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,62 @@ func TestFirstRunShowsEmulatorSelectionPrompt(t *testing.T) {
114114
<-outputCh
115115
}
116116

117+
func TestFirstRunPromptsForLoginBeforeEmulatorSelection(t *testing.T) {
118+
t.Parallel()
119+
if runtime.GOOS == "windows" {
120+
t.Skip("PTY not supported on Windows")
121+
}
122+
123+
mockServer := createMockAPIServer(t, "test-license-token", true)
124+
defer mockServer.Close()
125+
126+
tmpHome := t.TempDir()
127+
require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755))
128+
e := env.Environ(testEnvWithHome(tmpHome, tmpHome)).
129+
Without(env.AuthToken).
130+
With(env.APIEndpoint, mockServer.URL).
131+
With(env.DisableEvents, "1")
132+
133+
// No config exists so this is a first run; no token means login fires before emulator selection.
134+
configPath, _, err := runLstk(t, testContext(t), "", e, "config", "path")
135+
require.NoError(t, err)
136+
require.NoFileExists(t, configPath)
137+
138+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
139+
defer cancel()
140+
141+
cmd := exec.CommandContext(ctx, binaryPath(), "start")
142+
cmd.Env = e
143+
144+
ptmx, err := pty.Start(cmd)
145+
require.NoError(t, err, "failed to start lstk in PTY")
146+
defer func() { _ = ptmx.Close() }()
147+
148+
out := &syncBuffer{}
149+
outputCh := make(chan struct{})
150+
go func() {
151+
_, _ = io.Copy(out, ptmx)
152+
close(outputCh)
153+
}()
154+
155+
require.Eventually(t, func() bool {
156+
return bytes.Contains(out.Bytes(), []byte("Press any key when complete"))
157+
}, 10*time.Second, 100*time.Millisecond, "auth prompt should appear on first run when no token is set")
158+
159+
assert.NotContains(t, out.String(), "Which emulator would you like to use?",
160+
"emulator selection prompt must not appear before auth completes")
161+
162+
_, err = ptmx.Write([]byte("\r"))
163+
require.NoError(t, err)
164+
165+
require.Eventually(t, func() bool {
166+
return bytes.Contains(out.Bytes(), []byte("Which emulator would you like to use?"))
167+
}, 10*time.Second, 100*time.Millisecond, "emulator selection prompt should appear after auth completes")
168+
169+
cancel()
170+
<-outputCh
171+
}
172+
117173
func TestFirstRunNonInteractiveEmitsDefaultEmulatorNote(t *testing.T) {
118174
t.Parallel()
119175
tmpHome := t.TempDir()

0 commit comments

Comments
 (0)