Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
bin/
*.test

.idea
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Note: Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for
- `container/` - Handling different emulator containers
- `runtime/` - Abstraction for container runtimes (Docker, Kubernetes, etc.) - currently only Docker implemented
- `auth/` - Authentication (env var token or browser-based login)
- `output/` - Generic event and sink abstractions for CLI/TUI/non-interactive rendering

# Configuration

Expand All @@ -47,6 +48,13 @@ Environment variables:

- Prefer integration tests to cover most cases. Use unit tests when integration tests are not practical.

# Output Routing

- Emit progress/events through `internal/output` sinks instead of printing directly from command handlers.
- Use `output.NewPlainSink(...)` for CLI text output.
- Prefer typed `output.Sink` dependencies over raw callback functions like `func(string)`.
- Keep reusable output primitives in `internal/output`; command-specific orchestration can live in `cmd/`.

# Maintaining This File

When making significant changes to the codebase (new commands, architectural changes, build process updates, new patterns), update this CLAUDE.md file to reflect them.
6 changes: 4 additions & 2 deletions cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package cmd

import (
"fmt"
"os"

"github.com/localstack/lstk/internal/auth"
"github.com/localstack/lstk/internal/output"
"github.com/spf13/cobra"
)

Expand All @@ -12,7 +14,8 @@ var loginCmd = &cobra.Command{
Short: "Authenticate with LocalStack",
Long: "Authenticate with LocalStack and store credentials in system keyring",
RunE: func(cmd *cobra.Command, args []string) error {
a, err := auth.New()
sink := output.NewPlainSink(os.Stdout)
a, err := auth.New(sink)
if err != nil {
return fmt.Errorf("failed to initialize auth: %w", err)
}
Expand All @@ -22,7 +25,6 @@ var loginCmd = &cobra.Command{
return err
}

fmt.Println("Login successful.")
return nil
},
}
Expand Down
5 changes: 4 additions & 1 deletion cmd/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ package cmd

import (
"fmt"
"os"

"github.com/localstack/lstk/internal/auth"
"github.com/localstack/lstk/internal/output"
"github.com/spf13/cobra"
)

var logoutCmd = &cobra.Command{
Use: "logout",
Short: "Remove stored authentication token",
RunE: func(cmd *cobra.Command, args []string) error {
a, err := auth.New()
sink := output.NewPlainSink(os.Stdout)
a, err := auth.New(sink)
if err != nil {
return fmt.Errorf("failed to initialize auth: %w", err)
}
Expand Down
11 changes: 6 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/spf13/cobra"
)
Expand All @@ -25,11 +26,7 @@ var rootCmd = &cobra.Command{
os.Exit(1)
}

onProgress := func(msg string) {
fmt.Println(msg)
}

if err := container.Start(cmd.Context(), rt, onProgress); err != nil {
if err := runStart(cmd.Context(), rt); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
Expand All @@ -43,3 +40,7 @@ func init() {
func Execute(ctx context.Context) error {
return rootCmd.ExecuteContext(ctx)
}

func runStart(ctx context.Context, rt runtime.Runtime) error {
return container.Start(ctx, rt, output.NewPlainSink(os.Stdout))
}
7 changes: 1 addition & 6 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"os"

"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/runtime"
"github.com/spf13/cobra"
)
Expand All @@ -20,11 +19,7 @@ var startCmd = &cobra.Command{
os.Exit(1)
}

onProgress := func(msg string) {
fmt.Println(msg)
}

if err := container.Start(cmd.Context(), rt, onProgress); err != nil {
if err := runStart(cmd.Context(), rt); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
Expand Down
17 changes: 10 additions & 7 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,28 @@ package auth
import (
"context"
"errors"
"log"
"fmt"
"os"

"github.com/99designs/keyring"
"github.com/localstack/lstk/internal/output"
)

type Auth struct {
keyring Keyring
browserLogin LoginProvider
sink output.Sink
}

func New() (*Auth, error) {
func New(sink output.Sink) (*Auth, error) {
kr, err := newSystemKeyring()
if err != nil {
return nil, err
}
return &Auth{
keyring: kr,
browserLogin: newBrowserLogin(),
browserLogin: newBrowserLogin(sink),
sink: sink,
}, nil
}

Expand All @@ -35,18 +38,18 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) {
return token, nil
}

log.Println("Authentication required. Opening browser...")
output.EmitLog(a.sink, "Authentication required. Opening browser...")
token, err := a.browserLogin.Login(ctx)
if err != nil {
log.Println("Authentication failed.")
output.EmitWarning(a.sink, "Authentication failed.")
return "", err
}

if err := a.keyring.Set(keyringService, keyringUser, token); err != nil {
log.Printf("Warning: could not store token in keyring: %v", err)
output.EmitWarning(a.sink, fmt.Sprintf("could not store token in keyring: %v", err))
}

log.Println("Login successful.")
output.EmitLog(a.sink, "Login successful.")
return token, nil
}

Expand Down
25 changes: 17 additions & 8 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package auth

import (
"bytes"
"context"
"errors"
"log"
"os"
"strings"
"testing"

"github.com/localstack/lstk/internal/output"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)
Expand All @@ -24,9 +24,15 @@ func TestGetToken_ReturnsTokenWhenKeyringStoreFails(t *testing.T) {
mockKeyring := NewMockKeyring(ctrl)
mockLogin := NewMockLoginProvider(ctrl)

var events []any
sink := output.SinkFunc(func(event any) {
events = append(events, event)
})

auth := &Auth{
keyring: mockKeyring,
browserLogin: mockLogin,
sink: sink,
}

// Keyring returns empty (no stored token)
Expand All @@ -36,14 +42,17 @@ func TestGetToken_ReturnsTokenWhenKeyringStoreFails(t *testing.T) {
// Setting token in keyring fails
mockKeyring.EXPECT().Set(keyringService, keyringUser, "test-token").Return(errors.New("keyring unavailable"))

// Capture log output
var logBuf bytes.Buffer
log.SetOutput(&logBuf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })

token, err := auth.GetToken(context.Background())

assert.NoError(t, err)
assert.Equal(t, "test-token", token)
assert.Contains(t, logBuf.String(), "Warning: could not store token in keyring")
assert.Condition(t, func() bool {
for _, event := range events {
warningEvent, ok := event.(output.WarningEvent)
if ok && strings.Contains(warningEvent.Message, "could not store token in keyring") {
return true
}
}
return false
})
}
22 changes: 12 additions & 10 deletions internal/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import (
"bufio"
"context"
"fmt"
"log"
"net"
"net/http"
"os"

"github.com/localstack/lstk/internal/api"
"github.com/localstack/lstk/internal/output"
"github.com/pkg/browser"
)

Expand All @@ -24,11 +24,13 @@ type LoginProvider interface {

type browserLogin struct {
platformClient api.PlatformAPI
sink output.Sink
}

func newBrowserLogin() *browserLogin {
func newBrowserLogin(sink output.Sink) *browserLogin {
return &browserLogin{
platformClient: api.NewPlatformClient(),
sink: sink,
}
}

Expand Down Expand Up @@ -70,7 +72,7 @@ func (b *browserLogin) Login(ctx context.Context) (string, error) {
}
defer func() {
if err := server.Shutdown(ctx); err != nil {
log.Printf("failed to shutdown server: %v", err)
output.EmitWarning(b.sink, fmt.Sprintf("failed to shutdown server: %v", err))
}
}()

Expand All @@ -90,12 +92,12 @@ func (b *browserLogin) Login(ctx context.Context) (string, error) {

// Display device flow instructions
if browserOpened {
fmt.Printf("Browser didn't open? Open %s to authorize device.\n", deviceURL)
output.EmitLog(b.sink, fmt.Sprintf("Browser didn't open? Open %s to authorize device.", deviceURL))
} else {
fmt.Printf("Open %s to authorize device.\n", deviceURL)
output.EmitLog(b.sink, fmt.Sprintf("Open %s to authorize device.", deviceURL))
}
fmt.Printf("Verification code: %s\n", authReq.Code)
fmt.Println("Waiting for authentication... (Press ENTER when complete)")
output.EmitLog(b.sink, fmt.Sprintf("Verification code: %s", authReq.Code))
output.EmitLog(b.sink, "Waiting for authentication... (Press ENTER when complete)")

// Listen for ENTER key in background
go func() {
Expand Down Expand Up @@ -127,22 +129,22 @@ func getWebAppURL() string {
}

func (b *browserLogin) completeDeviceFlow(ctx context.Context, authReq *api.AuthRequest) (string, error) {
log.Println("Checking if auth request is confirmed...")
output.EmitLog(b.sink, "Checking if auth request is confirmed...")
confirmed, err := b.platformClient.CheckAuthRequestConfirmed(ctx, authReq.ID, authReq.ExchangeToken)
if err != nil {
return "", fmt.Errorf("failed to check auth request: %w", err)
}
if !confirmed {
return "", fmt.Errorf("auth request not confirmed - please enter the code in the browser first")
}
log.Println("Auth request confirmed, exchanging for token...")
output.EmitLog(b.sink, "Auth request confirmed, exchanging for token...")

bearerToken, err := b.platformClient.ExchangeAuthRequest(ctx, authReq.ID, authReq.ExchangeToken)
if err != nil {
return "", fmt.Errorf("failed to exchange auth request: %w", err)
}

log.Println("Fetching license token...")
output.EmitLog(b.sink, "Fetching license token...")
licenseToken, err := b.platformClient.GetLicenseToken(ctx, bearerToken)
if err != nil {
return "", fmt.Errorf("failed to get license token: %w", err)
Expand Down
Loading