diff --git a/internal/ui/run.go b/internal/ui/run.go index 6ecc5c78..d2825783 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -6,6 +6,7 @@ import ( "os" tea "github.com/charmbracelet/bubbletea" + "github.com/localstack/lstk/internal/auth" "github.com/localstack/lstk/internal/container" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" @@ -75,6 +76,19 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { p.Send(runDoneMsg{}) return } + // Resolve the auth token before any emulator-selection prompt so the user + // logs in first and only configures an emulator once they're authenticated. + // container.Start still calls GetToken as a safety net for non-interactive + // callers; once the token is in opts.AuthToken (or the keyring), it returns + // immediately. + if authErr := resolveAuthToken(ctx, sink, &runOpts); authErr != nil { + if errors.Is(authErr, context.Canceled) { + return + } + err = authErr + p.Send(runErrMsg{err: authErr}) + return + } if runOpts.NeedsEmulatorSelection { newContainers, selErr := container.SelectEmulator(ctx, sink, runOpts.ConfigPath) if selErr != nil { @@ -115,6 +129,23 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { return nil } +// resolveAuthToken ensures the user is authenticated before the start flow +// continues. On success, the resolved token is written to opts.StartOptions.AuthToken +// so container.Start short-circuits its own auth call. +func resolveAuthToken(ctx context.Context, sink output.Sink, opts *RunOptions) error { + tokenStorage, err := auth.NewTokenStorage(opts.StartOptions.ForceFileKeyring, opts.StartOptions.Logger) + if err != nil { + return err + } + a := auth.New(sink, opts.StartOptions.PlatformClient, tokenStorage, opts.StartOptions.AuthToken, opts.StartOptions.WebAppURL, true, "") + token, err := a.GetToken(ctx) + if err != nil { + return err + } + opts.StartOptions.AuthToken = token + return nil +} + func RunMessage(parentCtx context.Context, event output.MessageEvent) error { return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { sink.Emit(event) diff --git a/test/integration/emulator_select_test.go b/test/integration/emulator_select_test.go index 62c80931..4e2138a4 100644 --- a/test/integration/emulator_select_test.go +++ b/test/integration/emulator_select_test.go @@ -114,6 +114,62 @@ func TestFirstRunShowsEmulatorSelectionPrompt(t *testing.T) { <-outputCh } +func TestFirstRunPromptsForLoginBeforeEmulatorSelection(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + mockServer := createMockAPIServer(t, "test-license-token", true) + defer mockServer.Close() + + tmpHome := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) + e := env.Environ(testEnvWithHome(tmpHome, tmpHome)). + Without(env.AuthToken). + With(env.APIEndpoint, mockServer.URL). + With(env.DisableEvents, "1") + + // No config exists so this is a first run; no token means login fires before emulator selection. + configPath, _, err := runLstk(t, testContext(t), "", e, "config", "path") + require.NoError(t, err) + require.NoFileExists(t, configPath) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binaryPath(), "start") + cmd.Env = e + + ptmx, err := pty.Start(cmd) + require.NoError(t, err, "failed to start lstk in PTY") + defer func() { _ = ptmx.Close() }() + + out := &syncBuffer{} + outputCh := make(chan struct{}) + go func() { + _, _ = io.Copy(out, ptmx) + close(outputCh) + }() + + require.Eventually(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("Press any key when complete")) + }, 10*time.Second, 100*time.Millisecond, "auth prompt should appear on first run when no token is set") + + assert.NotContains(t, out.String(), "Which emulator would you like to use?", + "emulator selection prompt must not appear before auth completes") + + _, err = ptmx.Write([]byte("\r")) + require.NoError(t, err) + + require.Eventually(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("Which emulator would you like to use?")) + }, 10*time.Second, 100*time.Millisecond, "emulator selection prompt should appear after auth completes") + + cancel() + <-outputCh +} + func TestFirstRunNonInteractiveEmitsDefaultEmulatorNote(t *testing.T) { t.Parallel() tmpHome := t.TempDir()