Skip to content

Commit a55b170

Browse files
committed
Add lets user settings for color and update notifications
1 parent c259676 commit a55b170

8 files changed

Lines changed: 327 additions & 19 deletions

File tree

docs/docs/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ title: Changelog
1818
* `[Added]` Add `lets self doc` command to open the online documentation in a browser.
1919
* `[Added]` Show background update notifications for interactive sessions, with Homebrew-aware guidance and `LETS_CHECK_UPDATE` opt-out.
2020
* `[Changed]` Centralize the `lets:` log prefix in the formatter and render debug messages in blue.
21+
* `[Added]` Add user settings in `~/.config/lets/config.yaml` for lets behavior such as `no_color` and `upgrade_notify`, with env variables still taking precedence.
2122

2223
## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59)
2324

internal/cli/cli.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/lets-cli/lets/internal/executor"
1717
"github.com/lets-cli/lets/internal/logging"
1818
"github.com/lets-cli/lets/internal/set"
19+
"github.com/lets-cli/lets/internal/settings"
1920
"github.com/lets-cli/lets/internal/upgrade"
2021
"github.com/lets-cli/lets/internal/upgrade/registry"
2122
"github.com/lets-cli/lets/internal/workdir"
@@ -36,6 +37,14 @@ func Main(version string, buildDate string) int {
3637

3738
configDir := os.Getenv("LETS_CONFIG_DIR")
3839

40+
appSettings, err := settings.Load()
41+
if err != nil {
42+
_, _ = fmt.Fprintf(os.Stderr, "lets: settings error: %s\n", err)
43+
return 1
44+
}
45+
46+
appSettings.Apply()
47+
3948
logging.InitLogging(os.Stdout, os.Stderr)
4049

4150
rootCmd := cmd.CreateRootCommand(version, buildDate)
@@ -128,7 +137,7 @@ func Main(version string, buildDate string) int {
128137
return 0
129138
}
130139

131-
updateCh, cancelUpdateCheck := maybeStartUpdateCheck(ctx, version, command)
140+
updateCh, cancelUpdateCheck := maybeStartUpdateCheck(ctx, version, command, appSettings)
132141
defer cancelUpdateCheck()
133142

134143
if err := rootCmd.ExecuteContext(ctx); err != nil {
@@ -208,8 +217,9 @@ func maybeStartUpdateCheck(
208217
ctx context.Context,
209218
version string,
210219
command *cobra.Command,
220+
appSettings settings.Settings,
211221
) (<-chan updateCheckResult, context.CancelFunc) {
212-
if !shouldCheckForUpdate(command.Name(), isInteractiveStderr()) {
222+
if !shouldCheckForUpdate(command.Name(), isInteractiveStderr(), appSettings) {
213223
return nil, func() {}
214224
}
215225

@@ -262,8 +272,8 @@ func printUpdateNotice(updateCh <-chan updateCheckResult) {
262272
}
263273
}
264274

265-
func shouldCheckForUpdate(commandName string, interactive bool) bool {
266-
if !interactive || os.Getenv("CI") != "" || os.Getenv("LETS_CHECK_UPDATE") != "" {
275+
func shouldCheckForUpdate(commandName string, interactive bool, appSettings settings.Settings) bool {
276+
if !interactive || !appSettings.UpgradeNotify || os.Getenv("CI") != "" {
267277
return false
268278
}
269279

internal/cli/cli_test.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"testing"
55

66
cmdpkg "github.com/lets-cli/lets/internal/cmd"
7+
"github.com/lets-cli/lets/internal/settings"
78
"github.com/spf13/cobra"
89
)
910

@@ -59,38 +60,41 @@ func TestAllowsMissingConfig(t *testing.T) {
5960
}
6061

6162
func TestShouldCheckForUpdate(t *testing.T) {
63+
defaultSettings := settings.Default()
64+
6265
t.Run("should allow normal interactive commands", func(t *testing.T) {
6366
t.Setenv("CI", "")
64-
t.Setenv("LETS_CHECK_UPDATE", "")
6567

66-
if !shouldCheckForUpdate("lets", true) {
68+
if !shouldCheckForUpdate("lets", true, defaultSettings) {
6769
t.Fatal("expected update check to be enabled")
6870
}
6971
})
7072

7173
t.Run("should skip non interactive sessions", func(t *testing.T) {
72-
if shouldCheckForUpdate("lets", false) {
74+
if shouldCheckForUpdate("lets", false, defaultSettings) {
7375
t.Fatal("expected non-interactive session to skip update check")
7476
}
7577
})
7678

7779
t.Run("should skip when CI is set", func(t *testing.T) {
7880
t.Setenv("CI", "1")
79-
if shouldCheckForUpdate("lets", true) {
81+
if shouldCheckForUpdate("lets", true, defaultSettings) {
8082
t.Fatal("expected CI to skip update check")
8183
}
8284
})
8385

84-
t.Run("should skip when notifier disabled", func(t *testing.T) {
85-
t.Setenv("LETS_CHECK_UPDATE", "1")
86-
if shouldCheckForUpdate("lets", true) {
87-
t.Fatal("expected opt-out env to skip update check")
86+
t.Run("should skip when notifier disabled in settings", func(t *testing.T) {
87+
disabled := settings.Default()
88+
disabled.UpgradeNotify = false
89+
90+
if shouldCheckForUpdate("lets", true, disabled) {
91+
t.Fatal("expected disabled settings to skip update check")
8892
}
8993
})
9094

9195
t.Run("should skip internal commands", func(t *testing.T) {
9296
for _, name := range []string{"completion", "help", "lsp", "self"} {
93-
if shouldCheckForUpdate(name, true) {
97+
if shouldCheckForUpdate(name, true, defaultSettings) {
9498
t.Fatalf("expected %q to skip update check", name)
9599
}
96100
}

internal/settings/settings.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package settings
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/fatih/color"
8+
"github.com/lets-cli/lets/internal/util"
9+
"gopkg.in/yaml.v3"
10+
)
11+
12+
type FileSettings struct {
13+
NoColor *bool `yaml:"no_color"`
14+
UpgradeNotify *bool `yaml:"upgrade_notify"`
15+
}
16+
17+
type Settings struct {
18+
NoColor bool
19+
UpgradeNotify bool
20+
}
21+
22+
func Default() Settings {
23+
return Settings{
24+
NoColor: false,
25+
UpgradeNotify: true,
26+
}
27+
}
28+
29+
func Load() (Settings, error) {
30+
path, err := util.LetsUserFile("config.yaml")
31+
if err != nil {
32+
return Settings{}, err
33+
}
34+
35+
return LoadFile(path)
36+
}
37+
38+
func LoadFile(path string) (Settings, error) {
39+
cfg := Default()
40+
41+
file, err := os.Open(path)
42+
if err != nil {
43+
if os.IsNotExist(err) {
44+
applyEnvOverrides(&cfg)
45+
return cfg, nil
46+
}
47+
48+
return Settings{}, fmt.Errorf("failed to open settings file: %w", err)
49+
}
50+
51+
defer file.Close()
52+
53+
var fileSettings FileSettings
54+
decoder := yaml.NewDecoder(file)
55+
decoder.KnownFields(true)
56+
if err := decoder.Decode(&fileSettings); err != nil {
57+
return Settings{}, fmt.Errorf("failed to decode settings file: %w", err)
58+
}
59+
60+
if fileSettings.NoColor != nil {
61+
cfg.NoColor = *fileSettings.NoColor
62+
}
63+
64+
if fileSettings.UpgradeNotify != nil {
65+
cfg.UpgradeNotify = *fileSettings.UpgradeNotify
66+
}
67+
68+
applyEnvOverrides(&cfg)
69+
70+
return cfg, nil
71+
}
72+
73+
func (s Settings) Apply() {
74+
if s.NoColor {
75+
color.NoColor = true
76+
}
77+
}
78+
79+
func applyEnvOverrides(cfg *Settings) {
80+
if _, ok := os.LookupEnv("NO_COLOR"); ok {
81+
cfg.NoColor = true
82+
}
83+
84+
if _, ok := os.LookupEnv("LETS_CHECK_UPDATE"); ok {
85+
cfg.UpgradeNotify = false
86+
}
87+
}

internal/settings/settings_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package settings
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/fatih/color"
9+
)
10+
11+
func unsetEnv(t *testing.T, key string) {
12+
t.Helper()
13+
14+
oldValue, hadValue := os.LookupEnv(key)
15+
if err := os.Unsetenv(key); err != nil {
16+
t.Fatalf("failed to unset %s: %v", key, err)
17+
}
18+
19+
t.Cleanup(func() {
20+
if hadValue {
21+
_ = os.Setenv(key, oldValue)
22+
return
23+
}
24+
25+
_ = os.Unsetenv(key)
26+
})
27+
}
28+
29+
func TestLoadFile(t *testing.T) {
30+
t.Run("uses defaults when file is missing", func(t *testing.T) {
31+
unsetEnv(t, "NO_COLOR")
32+
unsetEnv(t, "LETS_CHECK_UPDATE")
33+
34+
cfg, err := LoadFile(filepath.Join(t.TempDir(), "missing.yaml"))
35+
if err != nil {
36+
t.Fatalf("LoadFile() error = %v", err)
37+
}
38+
39+
if cfg.NoColor {
40+
t.Fatal("expected no_color default to be false")
41+
}
42+
if !cfg.UpgradeNotify {
43+
t.Fatal("expected upgrade_notify default to be true")
44+
}
45+
})
46+
47+
t.Run("loads file values", func(t *testing.T) {
48+
unsetEnv(t, "NO_COLOR")
49+
unsetEnv(t, "LETS_CHECK_UPDATE")
50+
51+
path := filepath.Join(t.TempDir(), "config.yaml")
52+
err := os.WriteFile(path, []byte("no_color: true\nupgrade_notify: false\n"), 0o644)
53+
if err != nil {
54+
t.Fatalf("failed to write settings file: %v", err)
55+
}
56+
57+
cfg, err := LoadFile(path)
58+
if err != nil {
59+
t.Fatalf("LoadFile() error = %v", err)
60+
}
61+
62+
if !cfg.NoColor {
63+
t.Fatal("expected no_color to be true")
64+
}
65+
if cfg.UpgradeNotify {
66+
t.Fatal("expected upgrade_notify to be false")
67+
}
68+
})
69+
70+
t.Run("env overrides file values", func(t *testing.T) {
71+
path := filepath.Join(t.TempDir(), "config.yaml")
72+
err := os.WriteFile(path, []byte("no_color: false\nupgrade_notify: true\n"), 0o644)
73+
if err != nil {
74+
t.Fatalf("failed to write settings file: %v", err)
75+
}
76+
77+
t.Setenv("NO_COLOR", "")
78+
t.Setenv("LETS_CHECK_UPDATE", "1")
79+
80+
cfg, err := LoadFile(path)
81+
if err != nil {
82+
t.Fatalf("LoadFile() error = %v", err)
83+
}
84+
85+
if !cfg.NoColor {
86+
t.Fatal("expected NO_COLOR to override settings file")
87+
}
88+
if cfg.UpgradeNotify {
89+
t.Fatal("expected LETS_CHECK_UPDATE to disable notifications")
90+
}
91+
})
92+
93+
t.Run("rejects unknown fields", func(t *testing.T) {
94+
unsetEnv(t, "NO_COLOR")
95+
unsetEnv(t, "LETS_CHECK_UPDATE")
96+
97+
path := filepath.Join(t.TempDir(), "config.yaml")
98+
err := os.WriteFile(path, []byte("wat: true\n"), 0o644)
99+
if err != nil {
100+
t.Fatalf("failed to write settings file: %v", err)
101+
}
102+
103+
_, err = LoadFile(path)
104+
if err == nil {
105+
t.Fatal("expected error")
106+
}
107+
})
108+
}
109+
110+
func TestLoad(t *testing.T) {
111+
tmpDir := t.TempDir()
112+
t.Setenv("HOME", tmpDir)
113+
unsetEnv(t, "NO_COLOR")
114+
unsetEnv(t, "LETS_CHECK_UPDATE")
115+
116+
configPath := filepath.Join(tmpDir, ".config", "lets", "config.yaml")
117+
err := os.MkdirAll(filepath.Dir(configPath), 0o755)
118+
if err != nil {
119+
t.Fatalf("failed to create config dir: %v", err)
120+
}
121+
122+
err = os.WriteFile(configPath, []byte("no_color: true\n"), 0o644)
123+
if err != nil {
124+
t.Fatalf("failed to write settings file: %v", err)
125+
}
126+
127+
cfg, err := Load()
128+
if err != nil {
129+
t.Fatalf("Load() error = %v", err)
130+
}
131+
132+
if !cfg.NoColor {
133+
t.Fatal("expected loaded no_color to be true")
134+
}
135+
}
136+
137+
func TestApply(t *testing.T) {
138+
previous := color.NoColor
139+
t.Cleanup(func() {
140+
color.NoColor = previous
141+
})
142+
143+
color.NoColor = false
144+
145+
Settings{NoColor: true}.Apply()
146+
147+
if !color.NoColor {
148+
t.Fatal("expected Apply to disable colors")
149+
}
150+
}

internal/upgrade/notifier.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,12 +221,7 @@ func (n *UpdateNotifier) writeState(state notifierState) error {
221221
}
222222

223223
func letsStatePath() (string, error) {
224-
homeDir, err := os.UserHomeDir()
225-
if err != nil {
226-
return "", fmt.Errorf("failed to get user config dir: %w", err)
227-
}
228-
229-
return filepath.Join(homeDir, ".config", "lets", "state.yaml"), nil
224+
return util.LetsUserFile("state.yaml")
230225
}
231226

232227
func parseStableVersion(version string) (*semver.Version, bool) {

internal/util/lets_user_dir.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package util
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
func LetsUserDir() (string, error) {
10+
homeDir, err := os.UserHomeDir()
11+
if err != nil {
12+
return "", fmt.Errorf("failed to get home dir: %w", err)
13+
}
14+
15+
return filepath.Join(homeDir, ".config", "lets"), nil
16+
}
17+
18+
func LetsUserFile(name string) (string, error) {
19+
dir, err := LetsUserDir()
20+
if err != nil {
21+
return "", err
22+
}
23+
24+
return filepath.Join(dir, name), nil
25+
}

0 commit comments

Comments
 (0)