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
8 changes: 8 additions & 0 deletions .claude/skills/add-command/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ Create `test/integration/<name>_test.go` with:
- Use `cleanup()` and `t.Cleanup(cleanup)` for container state
- Use `context.WithTimeout` for all tests

## Telemetry

Every new command must emit an `lstk_command` telemetry event. Wrap the command's `RunE` with `commandWithTelemetry(name, tel, fn)` — this handles timing, exit code, and error message automatically.

Start and stop are exceptions: they emit `lstk_lifecycle` events in addition to `lstk_command`, so they manage their own telemetry manually instead of using `commandWithTelemetry`.

In the corresponding integration test, add an assertion that the `lstk_command` event was emitted.

## Anti-patterns to avoid

- Do NOT put business logic in `cmd/` — the command file should be thin wiring only
Expand Down
12 changes: 7 additions & 5 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,25 @@ import (
"fmt"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/telemetry"
"github.com/spf13/cobra"
)

func newConfigCmd() *cobra.Command {
func newConfigCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Manage configuration",
}
cmd.AddCommand(newConfigPathCmd())
cmd.AddCommand(newConfigPathCmd(cfg, tel))
return cmd
}

func newConfigPathCmd() *cobra.Command {
func newConfigPathCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
return &cobra.Command{
Use: "path",
Short: "Print the configuration file path",
RunE: func(cmd *cobra.Command, args []string) error {
RunE: commandWithTelemetry("config path", tel, func(cmd *cobra.Command, args []string) error {
path, err := cmd.Flags().GetString("config")
if err != nil {
return err
Expand All @@ -37,6 +39,6 @@ func newConfigPathCmd() *cobra.Command {

_, err = fmt.Fprintln(cmd.OutOrStdout(), configPath)
return err
},
}),
}
}
18 changes: 14 additions & 4 deletions cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,35 @@ import (
"fmt"

"github.com/localstack/lstk/internal/api"
"github.com/localstack/lstk/internal/auth"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/telemetry"
"github.com/localstack/lstk/internal/ui"
"github.com/localstack/lstk/internal/version"
"github.com/spf13/cobra"
)

func newLoginCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
func newLoginCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
return &cobra.Command{
Use: "login",
Short: "Manage login",
Long: "Manage login and store credentials in system keyring",
PreRunE: initConfig,
RunE: func(cmd *cobra.Command, args []string) error {
RunE: commandWithTelemetry("login", tel, func(cmd *cobra.Command, args []string) error {
if !isInteractiveMode(cfg) {
return fmt.Errorf("login requires an interactive terminal")
}
platformClient := api.NewPlatformClient(cfg.APIEndpoint, logger)
return ui.RunLogin(cmd.Context(), version.Version(), platformClient, cfg.AuthToken, cfg.ForceFileKeyring, cfg.WebAppURL, logger)
},
if err := ui.RunLogin(cmd.Context(), version.Version(), platformClient, cfg.AuthToken, cfg.ForceFileKeyring, cfg.WebAppURL, logger); err != nil {
return err
}
if tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring, logger); err == nil {
if token, err := tokenStorage.GetAuthToken(); err == nil && token != "" {
tel.SetAuthToken(token)
}
}
return nil
}),
}
}
7 changes: 4 additions & 3 deletions cmd/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,17 @@ import (
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/telemetry"
"github.com/localstack/lstk/internal/ui"
"github.com/spf13/cobra"
)

func newLogoutCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
func newLogoutCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Remove stored authentication credentials",
PreRunE: initConfig,
RunE: func(cmd *cobra.Command, args []string) error {
RunE: commandWithTelemetry("logout", tel, func(cmd *cobra.Command, args []string) error {
platformClient := api.NewPlatformClient(cfg.APIEndpoint, logger)
appConfig, err := config.Get()
if err != nil {
Expand Down Expand Up @@ -56,6 +57,6 @@ func newLogoutCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
}
}
return nil
},
}),
}
}
7 changes: 4 additions & 3 deletions cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@ import (
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/telemetry"
"github.com/spf13/cobra"
)

func newLogsCmd(cfg *env.Env) *cobra.Command {
func newLogsCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
cmd := &cobra.Command{
Use: "logs",
Short: "Show emulator logs",
Long: "Show logs from the emulator. Use --follow to stream in real-time.",
PreRunE: initConfig,
RunE: func(cmd *cobra.Command, args []string) error {
RunE: commandWithTelemetry("logs", tel, func(cmd *cobra.Command, args []string) error {
follow, err := cmd.Flags().GetBool("follow")
if err != nil {
return err
Expand All @@ -32,7 +33,7 @@ func newLogsCmd(cfg *env.Env) *cobra.Command {
return fmt.Errorf("failed to get config: %w", err)
}
return container.Logs(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers, follow)
},
}),
}
cmd.Flags().BoolP("follow", "f", false, "Follow log output")
return cmd
Expand Down
79 changes: 68 additions & 11 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import (
"fmt"
"os"
"path/filepath"
"time"

"github.com/localstack/lstk/internal/api"
"github.com/localstack/lstk/internal/auth"
"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/env"
Expand All @@ -17,6 +19,7 @@ import (
"github.com/localstack/lstk/internal/ui"
"github.com/localstack/lstk/internal/version"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
Expand All @@ -30,7 +33,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
if err != nil {
return err
}
return runStart(cmd.Context(), rt, cfg, tel, logger)
return runStart(cmd.Context(), cmd.Flags(), rt, cfg, tel, logger)
},
}

Expand All @@ -50,13 +53,13 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C

root.AddCommand(
newStartCmd(cfg, tel, logger),
newStopCmd(cfg),
newLoginCmd(cfg, logger),
newLogoutCmd(cfg, logger),
newStatusCmd(cfg),
newLogsCmd(cfg),
newConfigCmd(),
newUpdateCmd(cfg),
newStopCmd(cfg, tel),
newLoginCmd(cfg, tel, logger),
newLogoutCmd(cfg, tel, logger),
newStatusCmd(cfg, tel),
newLogsCmd(cfg, tel),
newConfigCmd(cfg, tel),
newUpdateCmd(cfg, tel),
)

return root
Expand All @@ -74,6 +77,16 @@ func Execute(ctx context.Context) error {
defer cleanup()
logger.Info("lstk %s starting", version.Version())

// Resolve auth token for telemetry: keyring first, then env var.
resolvedToken := cfg.AuthToken
if tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring, logger); err == nil {
if token, err := tokenStorage.GetAuthToken(); err == nil && token != "" {
resolvedToken = token
}
}
cfg.AuthToken = resolvedToken
tel.SetAuthToken(resolvedToken)

root := NewRootCmd(cfg, tel, logger)
root.SilenceErrors = true
root.SilenceUsage = true
Expand All @@ -87,9 +100,7 @@ func Execute(ctx context.Context) error {
return nil
}

func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger) error {
// TODO: replace map with a typed payload struct once event schema is finalised
tel.Emit(ctx, "cli_cmd", map[string]any{"cmd": "lstk start", "params": []string{}})
func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger) error {

appConfig, err := config.Get()
if err != nil {
Expand All @@ -105,6 +116,7 @@ func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *teleme
Containers: appConfig.Containers,
Env: appConfig.Env,
Logger: logger,
Telemetry: tel,
}

if isInteractiveMode(cfg) {
Expand All @@ -113,6 +125,51 @@ func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *teleme
return container.Start(ctx, rt, output.NewPlainSink(os.Stdout), opts, false)
}

func runStart(ctx context.Context, cmdFlags *pflag.FlagSet, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger) error {
startTime := time.Now()

var flags []string
cmdFlags.Visit(func(f *pflag.Flag) {
flags = append(flags, "--"+f.Name)
})

runErr := startEmulator(ctx, rt, cfg, tel, logger)

exitCode := 0
errorMsg := ""
if runErr != nil {
exitCode = 1
errorMsg = runErr.Error()
}
tel.EmitCommand(ctx, "start", flags, time.Since(startTime).Milliseconds(), exitCode, errorMsg)

return runErr
}

// wraps a RunE function so that an lstk_command event is emitted after every invocation
// used for commands that do not emit lstk_lifecycle events (i.e. status, logs, config path, etc)
func commandWithTelemetry(name string, tel *telemetry.Client, fn func(*cobra.Command, []string) error) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
startTime := time.Now()
runErr := fn(cmd, args)

var flags []string
cmd.Flags().Visit(func(f *pflag.Flag) {
flags = append(flags, "--"+f.Name)
})

exitCode := 0
errorMsg := ""
if runErr != nil {
exitCode = 1
errorMsg = runErr.Error()
}
tel.EmitCommand(cmd.Context(), name, flags, time.Since(startTime).Milliseconds(), exitCode, errorMsg)

return runErr
}
}

func isInteractiveMode(cfg *env.Env) bool {
return !cfg.NonInteractive && ui.IsInteractive()
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.
if err != nil {
return err
}
return runStart(cmd.Context(), rt, cfg, tel, logger)
return runStart(cmd.Context(), cmd.Flags(), rt, cfg, tel, logger)
},
}
}
7 changes: 4 additions & 3 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,18 @@ import (
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/telemetry"
"github.com/localstack/lstk/internal/ui"
"github.com/spf13/cobra"
)

func newStatusCmd(cfg *env.Env) *cobra.Command {
func newStatusCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show emulator status and deployed resources",
Long: "Show the status of a running emulator and its deployed resources",
PreRunE: initConfig,
RunE: func(cmd *cobra.Command, args []string) error {
RunE: commandWithTelemetry("status", tel, func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
Expand All @@ -38,6 +39,6 @@ func newStatusCmd(cfg *env.Env) *cobra.Command {
return ui.RunStatus(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, awsClient)
}
return container.Status(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, awsClient, output.NewPlainSink(os.Stdout))
},
}),
}
}
31 changes: 28 additions & 3 deletions cmd/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,27 @@ package cmd
import (
"fmt"
"os"
"time"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/telemetry"
"github.com/localstack/lstk/internal/ui"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

func newStopCmd(cfg *env.Env) *cobra.Command {
func newStopCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop emulator",
Long: "Stop emulator and services",
PreRunE: initConfig,
RunE: func(cmd *cobra.Command, args []string) error {
startTime := time.Now()
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
Expand All @@ -29,11 +33,32 @@ func newStopCmd(cfg *env.Env) *cobra.Command {
return fmt.Errorf("failed to get config: %w", err)
}

stopOpts := container.StopOptions{
Telemetry: tel,
}

var runErr error

if isInteractiveMode(cfg) {
return ui.RunStop(cmd.Context(), rt, appConfig.Containers)
runErr = ui.RunStop(cmd.Context(), rt, appConfig.Containers, stopOpts)
} else {
runErr = container.Stop(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers, stopOpts)
}

return container.Stop(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers)
exitCode := 0
errorMsg := ""
if runErr != nil {
exitCode = 1
errorMsg = runErr.Error()
}

var flags []string
cmd.Flags().Visit(func(f *pflag.Flag) {
flags = append(flags, "--"+f.Name)
})
tel.EmitCommand(cmd.Context(), "stop", flags, time.Since(startTime).Milliseconds(), exitCode, errorMsg)

return runErr
},
}
}
Loading
Loading