Skip to content

Commit 604723a

Browse files
committed
Add feedback command
1 parent 1aa2f2e commit 604723a

13 files changed

Lines changed: 704 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Note: Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for
3232
- `output/` - Generic event and sink abstractions for CLI/TUI/non-interactive rendering
3333
- `ui/` - Bubble Tea views for interactive output
3434
- `update/` - Self-update logic: version check via GitHub API, binary/Homebrew/npm update paths, archive extraction
35+
- `feedback/` - Feedback API client and metadata helpers used by `lstk feedback`
3536
- `log/` - Internal diagnostic logging (not for user-facing output — use `output/` for that)
3637

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

5859
Environment variables:
5960
- `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set)
61+
- `LSTK_API_ENDPOINT` - Override the LocalStack platform API endpoint (also used by `lstk feedback`)
6062

6163
# Code Style
6264

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Running `lstk` will automatically handle configuration setup and start LocalStac
5252
- **Browser-based login** — authenticate via browser and store credentials securely in the system keyring
5353
- **AWS CLI profile** — optionally configure a `localstack` profile in `~/.aws/` after start
5454
- **Self-update** — check for and install the latest `lstk` release with `lstk update`
55+
- **Feedback submission** — send CLI feedback directly to the LocalStack team with `lstk feedback`
5556
- **Shell completions** — bash, zsh, and fish completions included
5657

5758
## Authentication
@@ -184,8 +185,11 @@ lstk update
184185
# Show resolved config file path
185186
lstk config path
186187

188+
# Send feedback interactively
189+
lstk feedback
190+
187191
```
188192

189193
## Reporting bugs
190194

191-
Feedback is welcome! Use the repository issue tracker for bug reports or feature requests.
195+
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.

cmd/feedback.go

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"runtime"
10+
"strings"
11+
12+
"github.com/localstack/lstk/internal/auth"
13+
"github.com/localstack/lstk/internal/config"
14+
"github.com/localstack/lstk/internal/env"
15+
"github.com/localstack/lstk/internal/feedback"
16+
"github.com/localstack/lstk/internal/log"
17+
"github.com/localstack/lstk/internal/output"
18+
"github.com/localstack/lstk/internal/telemetry"
19+
"github.com/localstack/lstk/internal/ui"
20+
"github.com/localstack/lstk/internal/ui/styles"
21+
"github.com/localstack/lstk/internal/version"
22+
"github.com/spf13/cobra"
23+
"golang.org/x/term"
24+
)
25+
26+
func newFeedbackCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
27+
cmd := &cobra.Command{
28+
Use: "feedback",
29+
Short: "Send feedback",
30+
Long: "Send feedback directly to the LocalStack team.",
31+
RunE: commandWithTelemetry("feedback", tel, func(cmd *cobra.Command, args []string) error {
32+
sink := output.NewPlainSink(cmd.OutOrStdout())
33+
if !isInteractiveMode(cfg) {
34+
return fmt.Errorf("feedback requires an interactive terminal")
35+
}
36+
message, confirmed, err := collectFeedbackInteractively(cmd, sink, cfg)
37+
if err != nil {
38+
return err
39+
}
40+
if !confirmed {
41+
return nil
42+
}
43+
44+
if strings.TrimSpace(cfg.AuthToken) == "" {
45+
return fmt.Errorf("feedback requires authentication")
46+
}
47+
client := feedback.NewClient(cfg.APIEndpoint)
48+
submit := func(ctx context.Context, submitSink output.Sink) error {
49+
output.EmitSpinnerStart(submitSink, "Submitting feedback")
50+
err := client.Submit(ctx, feedback.SubmitInput{
51+
Message: message,
52+
AuthToken: cfg.AuthToken,
53+
Context: buildFeedbackContext(cfg),
54+
})
55+
output.EmitSpinnerStop(submitSink)
56+
if err != nil {
57+
return err
58+
}
59+
output.EmitInfo(submitSink, styles.Success.Render(output.SuccessMarker())+" Thank you for your feedback!")
60+
return nil
61+
}
62+
63+
err = ui.RunFeedback(cmd.Context(), submit)
64+
if err != nil {
65+
return err
66+
}
67+
return nil
68+
}),
69+
}
70+
return cmd
71+
}
72+
73+
func collectFeedbackInteractively(cmd *cobra.Command, sink output.Sink, cfg *env.Env) (string, bool, error) {
74+
file, ok := cmd.InOrStdin().(*os.File)
75+
if !ok {
76+
return "", false, fmt.Errorf("interactive feedback requires a terminal")
77+
}
78+
79+
output.EmitInfo(sink, "What's your feedback?")
80+
output.EmitSecondary(sink, styles.Secondary.Render("> Press enter to submit or esc to cancel"))
81+
82+
message, cancelled, err := readInteractiveLine(file, cmd.OutOrStdout())
83+
if err != nil {
84+
return "", false, err
85+
}
86+
if cancelled {
87+
output.EmitSecondary(sink, styles.Secondary.Render("Cancelled feedback submission"))
88+
return "", false, nil
89+
}
90+
if strings.TrimSpace(message) == "" {
91+
return "", false, fmt.Errorf("feedback message cannot be empty")
92+
}
93+
94+
ctx := buildFeedbackContext(cfg)
95+
output.EmitInfo(sink, "")
96+
output.EmitInfo(sink, "This report will include:")
97+
output.EmitInfo(sink, "- Feedback: "+styles.Secondary.Render(message))
98+
output.EmitInfo(sink, "- Version (lstk): "+styles.Secondary.Render(version.Version()))
99+
output.EmitInfo(sink, "- OS (arch): "+styles.Secondary.Render(fmt.Sprintf("%s (%s)", runtime.GOOS, runtime.GOARCH)))
100+
output.EmitInfo(sink, "- Installation: "+styles.Secondary.Render(orUnknown(ctx.InstallMethod)))
101+
output.EmitInfo(sink, "- Shell: "+styles.Secondary.Render(orUnknown(ctx.Shell)))
102+
output.EmitInfo(sink, "- Container runtime: "+styles.Secondary.Render(orUnknown(ctx.ContainerRuntime)))
103+
output.EmitInfo(sink, "- Auth: "+styles.Secondary.Render(authStatus(ctx.AuthConfigured)))
104+
output.EmitInfo(sink, "- Config: "+styles.Secondary.Render(orUnknown(ctx.ConfigPath)))
105+
output.EmitInfo(sink, "")
106+
output.EmitInfo(sink, renderConfirmationPrompt("Confirm submitting this feedback?"))
107+
108+
submit, err := readConfirmation(file, cmd.OutOrStdout())
109+
if err != nil {
110+
return "", false, err
111+
}
112+
if !submit {
113+
output.EmitSecondary(sink, styles.Secondary.Render("Cancelled feedback submission"))
114+
return "", false, nil
115+
}
116+
return message, true, nil
117+
}
118+
119+
func buildFeedbackContext(cfg *env.Env) feedback.Context {
120+
configPath, _ := config.ConfigFilePath()
121+
authConfigured := strings.TrimSpace(cfg.AuthToken) != ""
122+
if !authConfigured {
123+
if tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring, log.Nop()); err == nil {
124+
if token, err := tokenStorage.GetAuthToken(); err == nil && strings.TrimSpace(token) != "" {
125+
authConfigured = true
126+
}
127+
}
128+
}
129+
return feedback.Context{
130+
AuthConfigured: authConfigured,
131+
InstallMethod: feedback.DetectInstallMethod(),
132+
Shell: detectShell(),
133+
ContainerRuntime: detectContainerRuntime(cfg),
134+
ConfigPath: configPath,
135+
}
136+
}
137+
138+
func detectShell() string {
139+
shellPath := strings.TrimSpace(os.Getenv("SHELL"))
140+
if shellPath == "" {
141+
return "unknown"
142+
}
143+
return filepath.Base(shellPath)
144+
}
145+
146+
func authStatus(v bool) string {
147+
if v {
148+
return "Configured"
149+
}
150+
return "Not Configured"
151+
}
152+
153+
func detectContainerRuntime(cfg *env.Env) string {
154+
if strings.TrimSpace(cfg.DockerHost) != "" {
155+
return "docker"
156+
}
157+
158+
homeDir, err := os.UserHomeDir()
159+
if err != nil {
160+
return "docker"
161+
}
162+
163+
switch {
164+
case fileExists(filepath.Join(homeDir, ".orbstack", "run", "docker.sock")):
165+
return "orbstack"
166+
case fileExists(filepath.Join(homeDir, ".colima", "default", "docker.sock")),
167+
fileExists(filepath.Join(homeDir, ".colima", "docker.sock")):
168+
return "colima"
169+
default:
170+
return "docker"
171+
}
172+
}
173+
174+
func fileExists(path string) bool {
175+
_, err := os.Stat(path)
176+
return err == nil
177+
}
178+
179+
func orUnknown(v string) string {
180+
if strings.TrimSpace(v) == "" {
181+
return "unknown"
182+
}
183+
return v
184+
}
185+
186+
func renderConfirmationPrompt(question string) string {
187+
return styles.Secondary.Render("? ") +
188+
styles.Message.Render(question) +
189+
styles.Secondary.Render(" [Y/n]")
190+
}
191+
192+
func readInteractiveLine(in *os.File, out io.Writer) (string, bool, error) {
193+
state, err := term.MakeRaw(int(in.Fd()))
194+
if err != nil {
195+
return "", false, err
196+
}
197+
defer func() { _ = term.Restore(int(in.Fd()), state) }()
198+
199+
var buf []byte
200+
scratch := make([]byte, 1)
201+
for {
202+
if _, err := in.Read(scratch); err != nil {
203+
return "", false, err
204+
}
205+
switch scratch[0] {
206+
case '\r', '\n':
207+
_, _ = io.WriteString(out, "\r\n")
208+
return strings.TrimSpace(string(buf)), false, nil
209+
case 27:
210+
cancelled, err := readEscapeSequence(in)
211+
if err != nil {
212+
return "", false, err
213+
}
214+
if !cancelled {
215+
continue
216+
}
217+
_, _ = io.WriteString(out, "\r\n")
218+
return "", true, nil
219+
case 3:
220+
_, _ = io.WriteString(out, "\r\n")
221+
return "", true, nil
222+
case 127, 8:
223+
if len(buf) == 0 {
224+
continue
225+
}
226+
buf = buf[:len(buf)-1]
227+
_, _ = io.WriteString(out, "\b \b")
228+
default:
229+
if scratch[0] < 32 {
230+
continue
231+
}
232+
buf = append(buf, scratch[0])
233+
_, _ = out.Write(scratch)
234+
}
235+
}
236+
}
237+
238+
func readConfirmation(in *os.File, out io.Writer) (bool, error) {
239+
state, err := term.MakeRaw(int(in.Fd()))
240+
if err != nil {
241+
return false, err
242+
}
243+
defer func() { _ = term.Restore(int(in.Fd()), state) }()
244+
245+
scratch := make([]byte, 1)
246+
for {
247+
if _, err := in.Read(scratch); err != nil {
248+
return false, err
249+
}
250+
switch scratch[0] {
251+
case '\r', '\n', 'y', 'Y':
252+
_, _ = io.WriteString(out, "\r\n")
253+
return true, nil
254+
case 27:
255+
cancelled, err := readEscapeSequence(in)
256+
if err != nil {
257+
return false, err
258+
}
259+
if !cancelled {
260+
continue
261+
}
262+
_, _ = io.WriteString(out, "\r\n")
263+
return false, nil
264+
case 3, 'n', 'N':
265+
_, _ = io.WriteString(out, "\r\n")
266+
return false, nil
267+
}
268+
}
269+
}

cmd/feedback_escape_other.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//go:build !darwin && !linux
2+
3+
package cmd
4+
5+
import "os"
6+
7+
func readEscapeSequence(in *os.File) (bool, error) {
8+
return true, nil
9+
}

cmd/feedback_escape_unix.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//go:build darwin || linux
2+
3+
package cmd
4+
5+
import (
6+
"os"
7+
8+
"golang.org/x/sys/unix"
9+
)
10+
11+
func readEscapeSequence(in *os.File) (bool, error) {
12+
fd := int(in.Fd())
13+
if err := unix.SetNonblock(fd, true); err != nil {
14+
return false, err
15+
}
16+
defer func() { _ = unix.SetNonblock(fd, false) }()
17+
18+
buf := make([]byte, 8)
19+
n, err := unix.Read(fd, buf)
20+
if err == unix.EAGAIN || err == unix.EWOULDBLOCK || n == 0 {
21+
return true, nil
22+
}
23+
if err != nil {
24+
return false, err
25+
}
26+
return false, nil
27+
}

cmd/feedback_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/localstack/lstk/internal/env"
8+
"github.com/localstack/lstk/internal/log"
9+
"github.com/localstack/lstk/internal/telemetry"
10+
)
11+
12+
func TestFeedbackCommandAppearsInHelp(t *testing.T) {
13+
out, err := executeWithArgs(t, "--help")
14+
if err != nil {
15+
t.Fatalf("expected no error, got %v", err)
16+
}
17+
assertContains(t, out, "feedback")
18+
}
19+
20+
func TestFeedbackCommandRequiresInteractiveTerminal(t *testing.T) {
21+
root := NewRootCmd(&env.Env{
22+
NonInteractive: true,
23+
AuthToken: "Bearer auth-token",
24+
}, telemetry.New("", true), log.Nop())
25+
root.SetArgs([]string{"feedback"})
26+
27+
err := root.ExecuteContext(context.Background())
28+
if err == nil || err.Error() != "feedback requires an interactive terminal" {
29+
t.Fatalf("expected interactive terminal error, got %v", err)
30+
}
31+
}

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
6161
newLogsCmd(cfg, tel),
6262
newConfigCmd(cfg, tel),
6363
newUpdateCmd(cfg, tel),
64+
newFeedbackCmd(cfg, tel),
6465
)
6566

6667
return root

internal/env/env.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ type Env struct {
1818
ForceFileKeyring bool
1919
AnalyticsEndpoint string
2020

21-
NonInteractive bool
22-
GitHubToken string
21+
NonInteractive bool
22+
GitHubToken string
2323
}
2424

2525
// Init initializes environment variable configuration and returns the result.

0 commit comments

Comments
 (0)