Skip to content

Commit 5352cf5

Browse files
committed
Add interactive feedback command
1 parent 1aa2f2e commit 5352cf5

14 files changed

Lines changed: 1063 additions & 21 deletions

File tree

CLAUDE.md

Lines changed: 5 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/` - Linear issue creation client used by `lstk feedback`
3536
- `log/` - Internal diagnostic logging (not for user-facing output — use `output/` for that)
3637

3738
# Logging
@@ -57,6 +58,10 @@ 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_LINEAR_CLIENT_ID` - OAuth client ID used by `lstk feedback`
62+
- `LSTK_LINEAR_CLIENT_SECRET` - OAuth client secret used by `lstk feedback`
63+
- `LSTK_LINEAR_TEAM_ID` - Team ID used by `lstk feedback`
64+
- `LSTK_LINEAR_API_ENDPOINT` - Override the feedback API endpoint (useful in tests)
6065

6166
# Code Style
6267

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/config"
13+
"github.com/localstack/lstk/internal/env"
14+
"github.com/localstack/lstk/internal/feedback"
15+
"github.com/localstack/lstk/internal/output"
16+
"github.com/localstack/lstk/internal/telemetry"
17+
"github.com/localstack/lstk/internal/ui"
18+
"github.com/localstack/lstk/internal/ui/styles"
19+
"github.com/localstack/lstk/internal/version"
20+
"github.com/spf13/cobra"
21+
"golang.org/x/term"
22+
)
23+
24+
func newFeedbackCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
25+
cmd := &cobra.Command{
26+
Use: "feedback",
27+
Short: "Send feedback",
28+
Long: "Send feedback directly to the LocalStack team.",
29+
RunE: commandWithTelemetry("feedback", tel, func(cmd *cobra.Command, args []string) error {
30+
sink := output.NewPlainSink(cmd.OutOrStdout())
31+
if !isInteractiveMode(cfg) {
32+
return fmt.Errorf("feedback requires an interactive terminal")
33+
}
34+
message, confirmed, err := collectFeedbackInteractively(cmd, sink, cfg)
35+
if err != nil {
36+
return err
37+
}
38+
if !confirmed {
39+
return nil
40+
}
41+
42+
if cfg.LinearClientID == "" {
43+
return fmt.Errorf("feedback requires LSTK_LINEAR_CLIENT_ID")
44+
}
45+
if cfg.LinearClientSecret == "" {
46+
return fmt.Errorf("feedback requires LSTK_LINEAR_CLIENT_SECRET")
47+
}
48+
if cfg.LinearTeamID == "" {
49+
return fmt.Errorf("feedback requires LSTK_LINEAR_TEAM_ID")
50+
}
51+
52+
client := feedback.NewClient(cfg.LinearAPIEndpoint, cfg.LinearClientID, cfg.LinearClientSecret, cfg.LinearTeamID)
53+
var issue *feedback.Issue
54+
submit := func(ctx context.Context, submitSink output.Sink) error {
55+
output.EmitSpinnerStart(submitSink, "Submitting feedback")
56+
var err error
57+
issue, err = client.Submit(ctx, feedback.SubmitInput{
58+
Message: message,
59+
Context: buildFeedbackContext(cfg),
60+
})
61+
output.EmitSpinnerStop(submitSink)
62+
if err != nil {
63+
return err
64+
}
65+
output.EmitInfo(submitSink, styles.Success.Render(output.SuccessMarker())+" Thank you for your feedback!")
66+
output.EmitSecondary(submitSink, styles.Secondary.Render("> Feedback ID: "+issue.Identifier))
67+
return nil
68+
}
69+
70+
err = ui.RunFeedback(cmd.Context(), submit)
71+
if err != nil {
72+
return err
73+
}
74+
return nil
75+
}),
76+
}
77+
return cmd
78+
}
79+
80+
func collectFeedbackInteractively(cmd *cobra.Command, sink output.Sink, cfg *env.Env) (string, bool, error) {
81+
file, ok := cmd.InOrStdin().(*os.File)
82+
if !ok {
83+
return "", false, fmt.Errorf("interactive feedback requires a terminal")
84+
}
85+
86+
output.EmitInfo(sink, "What's your feedback?")
87+
output.EmitSecondary(sink, styles.Secondary.Render("> Press enter to submit or esc to cancel"))
88+
89+
message, cancelled, err := readInteractiveLine(file, cmd.OutOrStdout())
90+
if err != nil {
91+
return "", false, err
92+
}
93+
if cancelled {
94+
output.EmitSecondary(sink, styles.Secondary.Render("Cancelled feedback submission"))
95+
return "", false, nil
96+
}
97+
if strings.TrimSpace(message) == "" {
98+
return "", false, fmt.Errorf("feedback message cannot be empty")
99+
}
100+
101+
ctx := buildFeedbackContext(cfg)
102+
output.EmitInfo(sink, "")
103+
output.EmitInfo(sink, "This report will include:")
104+
output.EmitInfo(sink, "- Feedback: "+styles.Secondary.Render(message))
105+
output.EmitInfo(sink, "- Version (lstk): "+styles.Secondary.Render(version.Version()))
106+
output.EmitInfo(sink, "- OS (arch): "+styles.Secondary.Render(fmt.Sprintf("%s (%s)", runtime.GOOS, runtime.GOARCH)))
107+
output.EmitInfo(sink, "- Installation: "+styles.Secondary.Render(orUnknown(ctx.InstallMethod)))
108+
output.EmitInfo(sink, "- Shell: "+styles.Secondary.Render(orUnknown(ctx.Shell)))
109+
output.EmitInfo(sink, "- Container runtime: "+styles.Secondary.Render(orUnknown(ctx.ContainerRuntime)))
110+
output.EmitInfo(sink, "- Auth: "+styles.Secondary.Render(authStatus(ctx.AuthConfigured)))
111+
output.EmitInfo(sink, "- Config: "+styles.Secondary.Render(orUnknown(ctx.ConfigPath)))
112+
output.EmitInfo(sink, "")
113+
output.EmitInfo(sink, renderConfirmationPrompt("Confirm submitting this feedback?"))
114+
115+
submit, err := readConfirmation(file, cmd.OutOrStdout())
116+
if err != nil {
117+
return "", false, err
118+
}
119+
if !submit {
120+
output.EmitSecondary(sink, styles.Secondary.Render("Cancelled feedback submission"))
121+
return "", false, nil
122+
}
123+
return message, true, nil
124+
}
125+
126+
func buildFeedbackContext(cfg *env.Env) feedback.Context {
127+
configPath, _ := config.ConfigFilePath()
128+
129+
return feedback.Context{
130+
AuthConfigured: cfg.AuthToken != "",
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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
LinearClientID: "client-id",
24+
LinearClientSecret: "client-secret",
25+
LinearTeamID: "team-123",
26+
}, telemetry.New("", true), log.Nop())
27+
root.SetArgs([]string{"feedback"})
28+
29+
err := root.ExecuteContext(context.Background())
30+
if err == nil || err.Error() != "feedback requires an interactive terminal" {
31+
t.Fatalf("expected interactive terminal error, got %v", err)
32+
}
33+
}

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

0 commit comments

Comments
 (0)