Skip to content

Commit 6a1eb85

Browse files
committed
fix: address review findings across packages
- Handle defaults.Set error in config.Init - Cache terminal theme to avoid repeated capability queries - Return error instead of panicking in OpenBrowser - Restore original Out after StopPager, fix Bold writing to stdout - Sanitize repo path to prevent traversal in version cache - Only set CORS headers when origin is allowed - Use atomic counter for unique error reference IDs - Sort command groups for deterministic help output - Run onStop for successful onStart hooks on partial failure - Use dynamic ports in server and app tests to prevent CI flakes - Align Go version in docs with go.mod (1.25) - Remove unused tarURL field, fix PreRun preservation in AddJSONFlags - Handle embedded structs in StructExportData - Rename isTTYWriter to isTTY for clarity
1 parent 90a1347 commit 6a1eb85

16 files changed

Lines changed: 178 additions & 91 deletions

File tree

.github/workflows/lint.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,4 @@ jobs:
2626
- name: Run linter
2727
uses: golangci/golangci-lint-action@v7
2828
with:
29-
version: v2.11
29+
version: v2.11

MIGRATION.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ This guide covers migrating from the previous salt version to the new structure.
44

55
## Go version
66

7-
Update `go.mod` to require Go 1.24:
7+
Update `go.mod` to require Go 1.25:
88

99
```text
10-
go 1.24
10+
go 1.25
1111
```
1212

1313
## Packages removed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func main() {
6969
go get github.com/raystack/salt
7070
```
7171

72-
Requires Go 1.24+.
72+
Requires Go 1.25+.
7373

7474
## Packages
7575

app/app.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,11 @@ func (a *App) Start(ctx context.Context) error {
6464
a.telClean = cleanup
6565
}
6666

67-
// Run onStart hooks.
68-
for _, fn := range a.onStart {
67+
// Run onStart hooks. If one fails, run onStop for previously
68+
// successful hooks before returning.
69+
for i, fn := range a.onStart {
6970
if err := fn(ctx); err != nil {
71+
a.stopHooks(context.Background(), i)
7072
a.cleanup()
7173
return fmt.Errorf("app on_start: %w", err)
7274
}
@@ -91,12 +93,18 @@ func (a *App) Logger() *slog.Logger {
9193
}
9294

9395
func (a *App) stop(ctx context.Context) {
94-
for _, fn := range a.onStop {
95-
if err := fn(ctx); err != nil {
96+
a.stopHooks(ctx, len(a.onStop))
97+
a.cleanup()
98+
}
99+
100+
// stopHooks runs onStop hooks for the first n hooks (used for partial cleanup
101+
// when an onStart hook fails partway through).
102+
func (a *App) stopHooks(ctx context.Context, n int) {
103+
for i := 0; i < n && i < len(a.onStop); i++ {
104+
if err := a.onStop[i](ctx); err != nil {
96105
a.logger.Error("app on_stop hook error", "error", err)
97106
}
98107
}
99-
a.cleanup()
100108
}
101109

102110
func (a *App) cleanup() {

app/app_test.go

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io"
77
"log/slog"
8+
"net"
89
"net/http"
910
"testing"
1011
"time"
@@ -16,6 +17,18 @@ import (
1617

1718
func nopLogger() *slog.Logger { return slog.New(slog.DiscardHandler) }
1819

20+
// freeAddr returns a "127.0.0.1:<port>" string using a port that is free at
21+
// the time of the call. There is a small TOCTOU window, but it eliminates
22+
// hardcoded-port flakes in CI.
23+
func freeAddr(t *testing.T) string {
24+
t.Helper()
25+
ln, err := net.Listen("tcp", "127.0.0.1:0")
26+
require.NoError(t, err)
27+
addr := ln.Addr().String()
28+
ln.Close()
29+
return addr
30+
}
31+
1932
func TestNew(t *testing.T) {
2033
t.Run("creates app with defaults", func(t *testing.T) {
2134
a, err := app.New()
@@ -44,10 +57,11 @@ func TestNew(t *testing.T) {
4457
func TestAppStartAndShutdown(t *testing.T) {
4558
t.Run("starts with health check and h2c by default", func(t *testing.T) {
4659
ctx, cancel := context.WithCancel(context.Background())
60+
addr := freeAddr(t)
4761

4862
a, err := app.New(
4963
app.WithLogger(nopLogger()),
50-
app.WithAddr("127.0.0.1:18950"),
64+
app.WithAddr(addr),
5165
)
5266
require.NoError(t, err)
5367

@@ -57,7 +71,7 @@ func TestAppStartAndShutdown(t *testing.T) {
5771
time.Sleep(100 * time.Millisecond)
5872

5973
// Health check should be on by default at /ping
60-
resp, err := http.Get("http://127.0.0.1:18950/ping")
74+
resp, err := http.Get("http://" + addr + "/ping")
6175
require.NoError(t, err)
6276
defer resp.Body.Close()
6377
assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -74,10 +88,11 @@ func TestAppStartAndShutdown(t *testing.T) {
7488

7589
t.Run("runs onStart hooks", func(t *testing.T) {
7690
ctx, cancel := context.WithCancel(context.Background())
91+
addr := freeAddr(t)
7792

7893
var hookRan bool
7994
a, err := app.New(
80-
app.WithAddr("127.0.0.1:18951"),
95+
app.WithAddr(addr),
8196
app.WithOnStart(func(_ context.Context) error {
8297
hookRan = true
8398
return nil
@@ -96,10 +111,11 @@ func TestAppStartAndShutdown(t *testing.T) {
96111

97112
t.Run("runs onStop hooks", func(t *testing.T) {
98113
ctx, cancel := context.WithCancel(context.Background())
114+
addr := freeAddr(t)
99115

100116
var hookRan bool
101117
a, err := app.New(
102-
app.WithAddr("127.0.0.1:18952"),
118+
app.WithAddr(addr),
103119
app.WithOnStop(func(_ context.Context) error {
104120
hookRan = true
105121
return nil
@@ -119,10 +135,11 @@ func TestAppStartAndShutdown(t *testing.T) {
119135
t.Run("serves custom handler", func(t *testing.T) {
120136
ctx, cancel := context.WithCancel(context.Background())
121137
defer cancel()
138+
addr := freeAddr(t)
122139

123140
a, err := app.New(
124141
app.WithLogger(nopLogger()),
125-
app.WithAddr("127.0.0.1:18953"),
142+
app.WithAddr(addr),
126143
app.WithHandler("/hello", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
127144
fmt.Fprint(w, "world")
128145
})),
@@ -132,7 +149,7 @@ func TestAppStartAndShutdown(t *testing.T) {
132149
go a.Start(ctx)
133150
time.Sleep(100 * time.Millisecond)
134151

135-
resp, err := http.Get("http://127.0.0.1:18953/hello")
152+
resp, err := http.Get("http://" + addr + "/hello")
136153
require.NoError(t, err)
137154
defer resp.Body.Close()
138155

@@ -145,6 +162,7 @@ func TestAppStartAndShutdown(t *testing.T) {
145162
t.Run("applies explicit middleware", func(t *testing.T) {
146163
ctx, cancel := context.WithCancel(context.Background())
147164
defer cancel()
165+
addr := freeAddr(t)
148166

149167
addHeader := func(next http.Handler) http.Handler {
150168
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -155,7 +173,7 @@ func TestAppStartAndShutdown(t *testing.T) {
155173

156174
a, err := app.New(
157175
app.WithLogger(nopLogger()),
158-
app.WithAddr("127.0.0.1:18954"),
176+
app.WithAddr(addr),
159177
app.WithHTTPMiddleware(addHeader),
160178
app.WithHandler("/test", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
161179
w.WriteHeader(http.StatusOK)
@@ -166,7 +184,7 @@ func TestAppStartAndShutdown(t *testing.T) {
166184
go a.Start(ctx)
167185
time.Sleep(100 * time.Millisecond)
168186

169-
resp, err := http.Get("http://127.0.0.1:18954/test")
187+
resp, err := http.Get("http://" + addr + "/test")
170188
require.NoError(t, err)
171189
defer resp.Body.Close()
172190

@@ -176,9 +194,10 @@ func TestAppStartAndShutdown(t *testing.T) {
176194
})
177195

178196
t.Run("onStart failure returns error and runs cleanup", func(t *testing.T) {
197+
addr := freeAddr(t)
179198
var cleanupRan bool
180199
a, err := app.New(
181-
app.WithAddr("127.0.0.1:18955"),
200+
app.WithAddr(addr),
182201
app.WithOnStart(func(_ context.Context) error {
183202
return fmt.Errorf("migration failed")
184203
}),

cli/commander/layout.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"fmt"
66
"regexp"
7+
"sort"
78
"strings"
89

910
"github.com/muesli/termenv"
@@ -104,7 +105,14 @@ func buildHelpEntries(cmd *cobra.Command) []helpEntry {
104105
// No groups defined — show all commands under "Core commands"
105106
helpEntries = append(helpEntries, helpEntry{"Core commands", strings.Join(ungroupedCommands, "\n")})
106107
} else {
107-
for group, cmds := range groupCommands {
108+
// Sort group names for deterministic output.
109+
groupNames := make([]string, 0, len(groupCommands))
110+
for group := range groupCommands {
111+
groupNames = append(groupNames, group)
112+
}
113+
sort.Strings(groupNames)
114+
for _, group := range groupNames {
115+
cmds := groupCommands[group]
108116
helpEntries = append(helpEntries, helpEntry{fmt.Sprintf("%s commands", toTitle(group)), strings.Join(cmds, "\n")})
109117
}
110118
if len(ungroupedCommands) > 0 {

cli/export.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,17 @@ func AddJSONFlags(cmd *cobra.Command, exportTarget *Exporter, fields []string) {
7272
})
7373

7474
// Validate field names and set the exporter.
75-
oldPreRun := cmd.PreRunE
75+
// Preserve both PreRunE and PreRun (non-error variant).
76+
oldPreRunE := cmd.PreRunE
77+
oldPreRun := cmd.PreRun
78+
cmd.PreRun = nil // avoid cobra running both
7679
cmd.PreRunE = func(c *cobra.Command, args []string) error {
77-
if oldPreRun != nil {
78-
if err := oldPreRun(c, args); err != nil {
80+
if oldPreRunE != nil {
81+
if err := oldPreRunE(c, args); err != nil {
7982
return err
8083
}
84+
} else if oldPreRun != nil {
85+
oldPreRun(c, args)
8186
}
8287

8388
jsonFlag := c.Flags().Lookup("json")
@@ -221,16 +226,24 @@ func (e *jsonExporter) extractData(v reflect.Value) any {
221226
}
222227

223228
// fieldByTag finds a struct field whose `json` tag matches the given name.
229+
// It searches embedded structs recursively.
224230
func fieldByTag(v reflect.Value, name string) reflect.Value {
225231
t := v.Type()
226232
for i := 0; i < t.NumField(); i++ {
227-
tag := t.Field(i).Tag.Get("json")
233+
sf := t.Field(i)
234+
tag := sf.Tag.Get("json")
228235
if idx := strings.IndexByte(tag, ','); idx >= 0 {
229236
tag = tag[:idx]
230237
}
231238
if strings.EqualFold(tag, name) {
232239
return v.Field(i)
233240
}
241+
// Recurse into embedded (anonymous) struct fields.
242+
if sf.Anonymous && sf.Type.Kind() == reflect.Struct {
243+
if result := fieldByTag(v.Field(i), name); result.IsValid() {
244+
return result
245+
}
246+
}
234247
}
235248
return reflect.Value{}
236249
}

cli/iostreams.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type IOStreams struct {
3737

3838
pager *terminal.Pager
3939
pagerStarted bool
40+
origOut io.Writer
4041

4142
// lazily created
4243
output *printer.Output
@@ -45,14 +46,14 @@ type IOStreams struct {
4546

4647
// System creates IOStreams wired to the real terminal.
4748
func System() *IOStreams {
48-
outTTY := isTTYWriter(os.Stdout)
49+
outTTY := isTTY(os.Stdout)
4950
return &IOStreams{
5051
In: os.Stdin,
5152
Out: os.Stdout,
5253
ErrOut: os.Stderr,
53-
inTTY: isTTYWriter(os.Stdin),
54+
inTTY: isTTY(os.Stdin),
5455
outTTY: outTTY,
55-
errTTY: isTTYWriter(os.Stderr),
56+
errTTY: isTTY(os.Stderr),
5657
colorEnabled: outTTY && !termenv.EnvNoColor(),
5758
}
5859
}
@@ -127,6 +128,7 @@ func (s *IOStreams) StartPager() error {
127128
if err := p.Start(); err != nil {
128129
return err
129130
}
131+
s.origOut = s.Out
130132
s.Out = p.Out
131133
s.pager = p
132134
s.pagerStarted = true
@@ -139,6 +141,11 @@ func (s *IOStreams) StopPager() {
139141
if s.pager != nil && s.pagerStarted {
140142
s.pager.Stop()
141143
s.pagerStarted = false
144+
if s.origOut != nil {
145+
s.Out = s.origOut
146+
s.origOut = nil
147+
s.output = nil // invalidate cached Output
148+
}
142149
}
143150
}
144151

@@ -158,6 +165,6 @@ func (s *IOStreams) Prompter() prompt.Prompter {
158165
return s.prompter
159166
}
160167

161-
func isTTYWriter(f *os.File) bool {
168+
func isTTY(f *os.File) bool {
162169
return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
163170
}

cli/printer/printer.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import (
2626
"gopkg.in/yaml.v3"
2727
)
2828

29+
// cachedTheme is computed once at init to avoid querying terminal capabilities
30+
// on every color function call.
31+
var cachedTheme = newTheme()
32+
2933
// Output handles all terminal output for a CLI command.
3034
//
3135
// Data output (Table, JSON, YAML, Println) goes to the primary writer (stdout).
@@ -77,9 +81,9 @@ func (o *Output) Info(msg string) {
7781
fmt.Fprintln(o.errW, o.color(o.theme.Cyan, msg))
7882
}
7983

80-
// Bold prints a bold message.
84+
// Bold prints a bold message to stderr.
8185
func (o *Output) Bold(msg string) {
82-
fmt.Fprintln(o.w, termenv.String(msg).Bold().String())
86+
fmt.Fprintln(o.errW, termenv.String(msg).Bold().String())
8387
}
8488

8589
// Print prints a plain message.
@@ -224,25 +228,25 @@ func (o *Output) Progress(max int, description string) *progressbar.ProgressBar
224228
// --- Formatting helpers (return styled strings for composition) ---
225229

226230
// Green returns text styled in green.
227-
func Green(t string) string { return colorize(newTheme().Green, t) }
231+
func Green(t string) string { return colorize(cachedTheme.Green, t) }
228232

229233
// Yellow returns text styled in yellow.
230-
func Yellow(t string) string { return colorize(newTheme().Yellow, t) }
234+
func Yellow(t string) string { return colorize(cachedTheme.Yellow, t) }
231235

232236
// Cyan returns text styled in cyan.
233-
func Cyan(t string) string { return colorize(newTheme().Cyan, t) }
237+
func Cyan(t string) string { return colorize(cachedTheme.Cyan, t) }
234238

235239
// Red returns text styled in red.
236-
func Red(t string) string { return colorize(newTheme().Red, t) }
240+
func Red(t string) string { return colorize(cachedTheme.Red, t) }
237241

238242
// Grey returns text styled in grey.
239-
func Grey(t string) string { return colorize(newTheme().Grey, t) }
243+
func Grey(t string) string { return colorize(cachedTheme.Grey, t) }
240244

241245
// Blue returns text styled in blue.
242-
func Blue(t string) string { return colorize(newTheme().Blue, t) }
246+
func Blue(t string) string { return colorize(cachedTheme.Blue, t) }
243247

244248
// Magenta returns text styled in magenta.
245-
func Magenta(t string) string { return colorize(newTheme().Magenta, t) }
249+
func Magenta(t string) string { return colorize(cachedTheme.Magenta, t) }
246250

247251
// --- Formatted color helpers ---
248252

0 commit comments

Comments
 (0)