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 @@ -32,6 +32,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)

# Logging
Expand All @@ -57,6 +58,7 @@ Created automatically on first run with defaults. Supports emulator types (aws,

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`)

# Code Style

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Running `lstk` will automatically handle configuration setup and start LocalStac
- **Browser-based login** — authenticate via browser and store credentials securely in the system keyring
- **AWS CLI profile** — optionally configure a `localstack` profile in `~/.aws/` after start
- **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 @@ -184,8 +185,11 @@ lstk update
# Show resolved config file path
lstk config path

# Send feedback interactively
lstk feedback

```

## 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.
269 changes: 269 additions & 0 deletions cmd/feedback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
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: commandWithTelemetry("feedback", tel, 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 {
output.EmitSpinnerStart(submitSink, "Submitting feedback")
err := client.Submit(ctx, feedback.SubmitInput{
Message: message,
AuthToken: cfg.AuthToken,
Context: buildFeedbackContext(cfg),
})
output.EmitSpinnerStop(submitSink)
if err != nil {
return err
}
output.EmitInfo(submitSink, 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")
}

output.EmitInfo(sink, "What's your feedback?")
output.EmitSecondary(sink, 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 {
output.EmitSecondary(sink, 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)
output.EmitInfo(sink, "")
output.EmitInfo(sink, "This report will include:")
output.EmitInfo(sink, "- Feedback: "+styles.Secondary.Render(message))
output.EmitInfo(sink, "- Version (lstk): "+styles.Secondary.Render(version.Version()))
output.EmitInfo(sink, "- OS (arch): "+styles.Secondary.Render(fmt.Sprintf("%s (%s)", runtime.GOOS, runtime.GOARCH)))
output.EmitInfo(sink, "- Installation: "+styles.Secondary.Render(orUnknown(ctx.InstallMethod)))
output.EmitInfo(sink, "- Shell: "+styles.Secondary.Render(orUnknown(ctx.Shell)))
output.EmitInfo(sink, "- Container runtime: "+styles.Secondary.Render(orUnknown(ctx.ContainerRuntime)))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid container/runtime wording in this prompt.

This is user-facing CLI copy, but the repo guidelines reserve container/runtime terminology for internal implementation details. Please rename or remove this field in the confirmation block. As per coding guidelines, "In user-facing CLI/help/docs, prefer 'emulator' over 'container'/'runtime'; use 'container'/'runtime' only for internal implementation details."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/feedback.go` at line 109, The CLI confirmation line uses the forbidden
phrase "Container runtime"; update the user-facing string in the EmitInfo call
so it uses "Emulator" instead (e.g., change "- Container runtime: " to "-
Emulator: " or "- Emulator type: ") while keeping the same value source
(ctx.ContainerRuntime) and preserving styles.Secondary.Render and
orUnknown(ctx.ContainerRuntime) usage in output.EmitInfo.

output.EmitInfo(sink, "- Auth: "+styles.Secondary.Render(authStatus(ctx.AuthConfigured)))
output.EmitInfo(sink, "- Config: "+styles.Secondary.Render(orUnknown(ctx.ConfigPath)))
output.EmitInfo(sink, "")
output.EmitInfo(sink, renderConfirmationPrompt("Confirm submitting this feedback?"))

submit, err := readConfirmation(file, cmd.OutOrStdout())
if err != nil {
return "", false, err
}
if !submit {
output.EmitSecondary(sink, 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,
}
}

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)
}
}
}

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 @@ -61,6 +61,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
newLogsCmd(cfg, tel),
newConfigCmd(cfg, tel),
newUpdateCmd(cfg, tel),
newFeedbackCmd(cfg, tel),
)

return root
Expand Down
4 changes: 2 additions & 2 deletions internal/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ type Env struct {
ForceFileKeyring bool
AnalyticsEndpoint string

NonInteractive bool
GitHubToken string
NonInteractive bool
GitHubToken string
}

// Init initializes environment variable configuration and returns the result.
Expand Down
Loading
Loading