Skip to content
Draft
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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Note: Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for
- `output/` - Generic event and sink abstractions for CLI/TUI/non-interactive rendering
- `ui/` - Bubble Tea views for interactive output
- `update/` - Self-update logic: version check via GitHub API, binary/Homebrew/npm update paths, archive extraction
- `feedback/` - Feedback API client and metadata helpers used by `lstk feedback`
- `log/` - Internal diagnostic logging (not for user-facing output — use `output/` for that)
- `iac/` - Wrappers for third-party infrastructure as code tools (Terraform, AWS CDK, AWS SAM CLI).

Expand Down Expand Up @@ -114,6 +115,7 @@ The default `lstk az <args>` mode mirrors `lstk aws`: the Azure CLI has no `--en

Environment variables:
- `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set)
- `LSTK_API_ENDPOINT` - Override the LocalStack platform API endpoint (also used by `lstk feedback`)
- `LSTK_OTEL=1` - Enables OpenTelemetry trace export (disabled by default); when enabled, standard `OTEL_EXPORTER_OTLP_*` env vars are respected by the SDK. Requires an OTLP-compatible backend to receive and visualize telemetry — for local development, `make otel` starts one (UI at http://localhost:16686).

# Infrastructure as Code Commands
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Running `lstk` will automatically handle configuration setup and start LocalStac
- **Terraform integration** — proxy Terraform commands to LocalStack with automatic AWS provider endpoint configuration
- **CDK integration** — proxy AWS CDK commands to LocalStack with automatic endpoint configuration (requires AWS CDK >= 2.177.0)
- **Self-update** — check for and install the latest `lstk` release with `lstk update`
- **Feedback submission** — send CLI feedback directly to the LocalStack team with `lstk feedback`
- **Shell completions** — bash, zsh, and fish completions included

## Authentication
Expand Down Expand Up @@ -346,6 +347,9 @@ lstk cdk deploy --require-approval never
# Synthesize a CDK app (offline, no running emulator needed)
lstk cdk synth

# Send feedback interactively
lstk feedback

```

## Snapshots
Expand Down Expand Up @@ -437,4 +441,4 @@ See [docs/extensions-authoring.md](docs/extensions-authoring.md) for the extensi

## Reporting bugs

Feedback is welcome! Use the repository issue tracker for bug reports or feature requests.
Feedback is welcome! You can submit feedback from the CLI with the `feedback` command or use the repository issue tracker for bug reports and feature requests.
272 changes: 272 additions & 0 deletions cmd/feedback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
package cmd

import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"

"github.com/localstack/lstk/internal/auth"
"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/feedback"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/telemetry"
"github.com/localstack/lstk/internal/ui"
"github.com/localstack/lstk/internal/ui/styles"
"github.com/localstack/lstk/internal/version"
"github.com/spf13/cobra"
"golang.org/x/term"
)

func newFeedbackCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
cmd := &cobra.Command{
Use: "feedback",
Short: "Send feedback",
Long: "Send feedback directly to the LocalStack team.",
RunE: func(cmd *cobra.Command, args []string) error {
sink := output.NewPlainSink(cmd.OutOrStdout())
if !isInteractiveMode(cfg) {
return fmt.Errorf("feedback requires an interactive terminal")
}
message, confirmed, err := collectFeedbackInteractively(cmd, sink, cfg)
if err != nil {
return err
}
if !confirmed {
return nil
}

if strings.TrimSpace(cfg.AuthToken) == "" {
return fmt.Errorf("feedback requires authentication")
}
client := feedback.NewClient(cfg.APIEndpoint)
submit := func(ctx context.Context, submitSink output.Sink) error {
submitSink.Emit(output.SpinnerStart("Submitting feedback"))
err := client.Submit(ctx, feedback.SubmitInput{
Message: message,
AuthToken: cfg.AuthToken,
Context: buildFeedbackContext(cfg),
})
submitSink.Emit(output.SpinnerStop())
if err != nil {
return err
}
submitSink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: styles.Success.Render(output.SuccessMarker()) + " Thank you for your feedback!"})
return nil
}

err = ui.RunFeedback(cmd.Context(), submit)
if err != nil {
return err
}
return nil
},
}
return cmd
}

func collectFeedbackInteractively(cmd *cobra.Command, sink output.Sink, cfg *env.Env) (string, bool, error) {
file, ok := cmd.InOrStdin().(*os.File)
if !ok {
return "", false, fmt.Errorf("interactive feedback requires a terminal")
}

sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: "What's your feedback?"})
sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: styles.Secondary.Render("> Press enter to submit or esc to cancel")})

message, cancelled, err := readInteractiveLine(file, cmd.OutOrStdout())
if err != nil {
return "", false, err
}
if cancelled {
sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: styles.Secondary.Render("Cancelled feedback submission")})
return "", false, nil
}
if strings.TrimSpace(message) == "" {
return "", false, fmt.Errorf("feedback message cannot be empty")
}

ctx := buildFeedbackContext(cfg)
emitInfo := func(text string) {
sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: text})
}
emitInfo("")
emitInfo("This report will include:")
emitInfo("- Feedback: " + styles.Secondary.Render(message))
emitInfo("- Version (lstk): " + styles.Secondary.Render(version.Version()))
emitInfo("- OS (arch): " + styles.Secondary.Render(fmt.Sprintf("%s (%s)", runtime.GOOS, runtime.GOARCH)))
emitInfo("- Installation: " + styles.Secondary.Render(orUnknown(ctx.InstallMethod)))
emitInfo("- Shell: " + styles.Secondary.Render(orUnknown(ctx.Shell)))
emitInfo("- Container runtime: " + styles.Secondary.Render(orUnknown(ctx.ContainerRuntime)))
emitInfo("- Auth: " + styles.Secondary.Render(authStatus(ctx.AuthConfigured)))
emitInfo("- Config: " + styles.Secondary.Render(orUnknown(ctx.ConfigPath)))
emitInfo("")
emitInfo(renderConfirmationPrompt("Confirm submitting this feedback?"))

submit, err := readConfirmation(file, cmd.OutOrStdout())
if err != nil {
return "", false, err
}
if !submit {
sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: styles.Secondary.Render("Cancelled feedback submission")})
return "", false, nil
}
return message, true, nil
}

func buildFeedbackContext(cfg *env.Env) feedback.Context {
configPath, _ := config.ConfigFilePath()
authConfigured := strings.TrimSpace(cfg.AuthToken) != ""
if !authConfigured {
if tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring, log.Nop()); err == nil {
if token, err := tokenStorage.GetAuthToken(); err == nil && strings.TrimSpace(token) != "" {
authConfigured = true
}
}
}
return feedback.Context{
AuthConfigured: authConfigured,
InstallMethod: feedback.DetectInstallMethod(),
Shell: detectShell(),
ContainerRuntime: detectContainerRuntime(cfg),
ConfigPath: configPath,
}
Comment thread
gtsiolis marked this conversation as resolved.
}

func detectShell() string {
shellPath := strings.TrimSpace(os.Getenv("SHELL"))
if shellPath == "" {
return "unknown"
}
return filepath.Base(shellPath)
}

func authStatus(v bool) string {
if v {
return "Configured"
}
return "Not Configured"
}

func detectContainerRuntime(cfg *env.Env) string {
if strings.TrimSpace(cfg.DockerHost) != "" {
return "docker"
}

homeDir, err := os.UserHomeDir()
if err != nil {
return "docker"
}

switch {
case fileExists(filepath.Join(homeDir, ".orbstack", "run", "docker.sock")):
return "orbstack"
case fileExists(filepath.Join(homeDir, ".colima", "default", "docker.sock")),
fileExists(filepath.Join(homeDir, ".colima", "docker.sock")):
return "colima"
default:
return "docker"
}
}

func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}

func orUnknown(v string) string {
if strings.TrimSpace(v) == "" {
return "unknown"
}
return v
}

func renderConfirmationPrompt(question string) string {
return styles.Secondary.Render("? ") +
styles.Message.Render(question) +
styles.Secondary.Render(" [Y/n]")
}

func readInteractiveLine(in *os.File, out io.Writer) (string, bool, error) {
state, err := term.MakeRaw(int(in.Fd()))
if err != nil {
return "", false, err
}
defer func() { _ = term.Restore(int(in.Fd()), state) }()

var buf []byte
scratch := make([]byte, 1)
for {
if _, err := in.Read(scratch); err != nil {
return "", false, err
}
switch scratch[0] {
case '\r', '\n':
_, _ = io.WriteString(out, "\r\n")
return strings.TrimSpace(string(buf)), false, nil
case 27:
cancelled, err := readEscapeSequence(in)
if err != nil {
return "", false, err
}
if !cancelled {
continue
}
_, _ = io.WriteString(out, "\r\n")
return "", true, nil
case 3:
_, _ = io.WriteString(out, "\r\n")
return "", true, nil
case 127, 8:
if len(buf) == 0 {
continue
}
buf = buf[:len(buf)-1]
_, _ = io.WriteString(out, "\b \b")
default:
if scratch[0] < 32 {
continue
}
buf = append(buf, scratch[0])
_, _ = out.Write(scratch)
Comment thread
gtsiolis marked this conversation as resolved.
}
}
}

func readConfirmation(in *os.File, out io.Writer) (bool, error) {
state, err := term.MakeRaw(int(in.Fd()))
if err != nil {
return false, err
}
defer func() { _ = term.Restore(int(in.Fd()), state) }()

scratch := make([]byte, 1)
for {
if _, err := in.Read(scratch); err != nil {
return false, err
}
switch scratch[0] {
case '\r', '\n', 'y', 'Y':
_, _ = io.WriteString(out, "\r\n")
return true, nil
case 27:
cancelled, err := readEscapeSequence(in)
if err != nil {
return false, err
}
if !cancelled {
continue
}
_, _ = io.WriteString(out, "\r\n")
return false, nil
case 3, 'n', 'N':
_, _ = io.WriteString(out, "\r\n")
return false, nil
}
}
}
9 changes: 9 additions & 0 deletions cmd/feedback_escape_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !darwin && !linux

package cmd

import "os"

func readEscapeSequence(in *os.File) (bool, error) {
return true, nil
}
27 changes: 27 additions & 0 deletions cmd/feedback_escape_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//go:build darwin || linux

package cmd

import (
"os"

"golang.org/x/sys/unix"
)

func readEscapeSequence(in *os.File) (bool, error) {
fd := int(in.Fd())
if err := unix.SetNonblock(fd, true); err != nil {
return false, err
}
defer func() { _ = unix.SetNonblock(fd, false) }()

buf := make([]byte, 8)
n, err := unix.Read(fd, buf)
if err == unix.EAGAIN || err == unix.EWOULDBLOCK || n == 0 {
return true, nil
}
if err != nil {
return false, err
}
return false, nil
}
31 changes: 31 additions & 0 deletions cmd/feedback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package cmd

import (
"context"
"testing"

"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/telemetry"
)

func TestFeedbackCommandAppearsInHelp(t *testing.T) {
out, err := executeWithArgs(t, "--help")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertContains(t, out, "feedback")
}

func TestFeedbackCommandRequiresInteractiveTerminal(t *testing.T) {
root := NewRootCmd(&env.Env{
NonInteractive: true,
AuthToken: "Bearer auth-token",
}, telemetry.New("", true), log.Nop())
root.SetArgs([]string{"feedback"})

err := root.ExecuteContext(context.Background())
if err == nil || err.Error() != "feedback requires an interactive terminal" {
t.Fatalf("expected interactive terminal error, got %v", err)
}
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
newResetCmd(cfg),
newSaveCmd(cfg),
newLoadCmd(cfg, tel, logger),
newFeedbackCmd(cfg, tel),
}
for _, c := range commands {
c.GroupID = groupCommands
Expand Down
Loading
Loading