From e0ac25c5b62fa51044cf4c19173e1dbdf9a93476 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sun, 14 Jun 2026 19:27:10 +0300 Subject: [PATCH 1/2] Add self config commands --- docs/docs/changelog.md | 1 + docs/docs/settings.md | 12 ++++++ internal/cmd/root_test.go | 54 +++++++++++++++++++++++++++ internal/cmd/self.go | 10 +++++ internal/cmd/self_config.go | 73 +++++++++++++++++++++++++++++++++++++ internal/util/editor.go | 26 +++++++++++++ 6 files changed, 176 insertions(+) create mode 100644 internal/cmd/self_config.go create mode 100644 internal/util/editor.go diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 7fd58685..3f5d27a7 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -5,6 +5,7 @@ title: Changelog ## [Unreleased](https://github.com/lets-cli/lets/releases/tag/v0.0.X) +* `[Added]` Add `lets self config path` and `lets self config edit` for user settings. Issue [#370](https://github.com/lets-cli/lets/issues/370) * `[Added]` Show interactive download progress for remote configs and remote mixins. Issue [#360](https://github.com/lets-cli/lets/issues/360) * `[Fixed]` Make `--no-cache` re-download remote mixins for local and remote configs. Issue [#365](https://github.com/lets-cli/lets/issues/365) * `[Added]` Remote config support: `lets -c https://url` downloads and caches config to `~/.config/lets/remote-configs/`. Use `--no-cache` to force re-download. diff --git a/docs/docs/settings.md b/docs/docs/settings.md index 02c281f7..98dc7297 100644 --- a/docs/docs/settings.md +++ b/docs/docs/settings.md @@ -17,6 +17,18 @@ Use settings for things like colored output, theming, or update notifications. D This file is per-user and applies to all projects on the machine. +Print the path: + +```bash +lets self config path +``` + +Open the file in `$EDITOR`: + +```bash +lets self config edit +``` + ## Precedence Settings are resolved in this order: diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index c3ac84f5..7ded1de6 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "path/filepath" "strings" "testing" @@ -236,6 +237,59 @@ func TestSelfCmd(t *testing.T) { } }) + t.Run("should print user config path", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + bufOut := new(bytes.Buffer) + + rootCmd := CreateRootCommand("v0.0.0-test", "") + rootCmd.SetArgs([]string{"self", "config", "path"}) + rootCmd.SetOut(bufOut) + rootCmd.SetErr(bufOut) + initSelfCmd(rootCmd, "v0.0.0-test", func(string) error { return nil }) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := filepath.Join(home, ".config", "lets", "config.yaml") + "\n" + if bufOut.String() != expected { + t.Fatalf("expected %q, got %q", expected, bufOut.String()) + } + }) + + t.Run("should open user config in editor", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + bufOut := new(bytes.Buffer) + called := false + gotPath := "" + + rootCmd := CreateRootCommand("v0.0.0-test", "") + rootCmd.SetArgs([]string{"self", "config", "edit"}) + rootCmd.SetOut(bufOut) + rootCmd.SetErr(bufOut) + initSelfCmdWithEditor(rootCmd, "v0.0.0-test", func(string) error { return nil }, func(path string) error { + called = true + gotPath = path + + return nil + }) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := filepath.Join(home, ".config", "lets", "config.yaml") + if !called { + t.Fatal("expected editor to be called") + } + + if gotPath != expected { + t.Fatalf("expected editor path %q, got %q", expected, gotPath) + } + }) + t.Run("should open documentation in browser", func(t *testing.T) { bufOut := new(bytes.Buffer) called := false diff --git a/internal/cmd/self.go b/internal/cmd/self.go index 9b0d2398..3b39e509 100644 --- a/internal/cmd/self.go +++ b/internal/cmd/self.go @@ -11,6 +11,15 @@ func InitSelfCmd(rootCmd *cobra.Command, version string) { } func initSelfCmd(rootCmd *cobra.Command, version string, openURL func(string) error) { + initSelfCmdWithEditor(rootCmd, version, openURL, util.OpenEditor) +} + +func initSelfCmdWithEditor( + rootCmd *cobra.Command, + version string, + openURL func(string) error, + openEditor func(string) error, +) { selfCmd := &cobra.Command{ Use: "self", Hidden: false, @@ -24,6 +33,7 @@ func initSelfCmd(rootCmd *cobra.Command, version string, openURL func(string) er rootCmd.AddCommand(selfCmd) + selfCmd.AddCommand(initConfigCommand(openEditor)) selfCmd.AddCommand(initDocCommand(openURL)) selfCmd.AddCommand(initLspCommand(version)) selfCmd.AddCommand(initSkillsCommand()) diff --git a/internal/cmd/self_config.go b/internal/cmd/self_config.go new file mode 100644 index 00000000..4c52caf6 --- /dev/null +++ b/internal/cmd/self_config.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/lets-cli/lets/internal/util" + "github.com/spf13/cobra" +) + +func initConfigCommand(openEditor func(string) error) *cobra.Command { + configCmd := &cobra.Command{ + Use: "config ", + Short: "Manage lets user config", + Long: strings.TrimSpace(`Manage the per-user lets settings file. + +The user config is stored at ~/.config/lets/config.yaml and applies to all +projects on the machine.`), + Args: validateCommandArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + configCmd.AddCommand(initConfigPathCommand()) + configCmd.AddCommand(initConfigEditCommand(openEditor)) + + return configCmd +} + +func initConfigPathCommand() *cobra.Command { + return &cobra.Command{ + Use: "path", + Short: "Print lets user config path", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := util.LetsUserFile("config.yaml") + if err != nil { + return err + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), path) + + return err + }, + } +} + +func initConfigEditCommand(openEditor func(string) error) *cobra.Command { + return &cobra.Command{ + Use: "edit", + Short: "Open lets user config in EDITOR", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := util.LetsUserFile("config.yaml") + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("creating config directory: %w", err) + } + + if err := openEditor(path); err != nil { + return fmt.Errorf("can not open config: %w", err) + } + + return nil + }, + } +} diff --git a/internal/util/editor.go b/internal/util/editor.go new file mode 100644 index 00000000..11199d88 --- /dev/null +++ b/internal/util/editor.go @@ -0,0 +1,26 @@ +package util + +import ( + "errors" + "fmt" + "os" + "os/exec" +) + +func OpenEditor(path string) error { + editor := os.Getenv("EDITOR") + if editor == "" { + return errors.New("EDITOR is not set") + } + + cmd := exec.Command(editor, path) //nolint:gosec + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("run %s: %w", editor, err) + } + + return nil +} From c7794ad9aface50d4a0e0c667ac4f090157deab3 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sun, 14 Jun 2026 19:32:15 +0300 Subject: [PATCH 2/2] Restrict self config directory permissions --- internal/cmd/root_test.go | 20 +++++++++++++++----- internal/cmd/self.go | 13 +++---------- internal/cmd/self_config.go | 2 +- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 7ded1de6..c0bee6e4 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "os" "path/filepath" "strings" "testing" @@ -219,7 +220,7 @@ func TestSelfCmd(t *testing.T) { t.Run("should use help func when run without args", func(t *testing.T) { rootCmd := CreateRootCommand("v0.0.0-test", "") rootCmd.SetArgs([]string{"self"}) - initSelfCmd(rootCmd, "v0.0.0-test", func(string) error { return nil }) + initSelfCmd(rootCmd, "v0.0.0-test", func(string) error { return nil }, func(string) error { return nil }) called := false rootCmd.SetHelpFunc(func(c *cobra.Command, args []string) { @@ -246,7 +247,7 @@ func TestSelfCmd(t *testing.T) { rootCmd.SetArgs([]string{"self", "config", "path"}) rootCmd.SetOut(bufOut) rootCmd.SetErr(bufOut) - initSelfCmd(rootCmd, "v0.0.0-test", func(string) error { return nil }) + initSelfCmd(rootCmd, "v0.0.0-test", func(string) error { return nil }, func(string) error { return nil }) if err := rootCmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -269,7 +270,7 @@ func TestSelfCmd(t *testing.T) { rootCmd.SetArgs([]string{"self", "config", "edit"}) rootCmd.SetOut(bufOut) rootCmd.SetErr(bufOut) - initSelfCmdWithEditor(rootCmd, "v0.0.0-test", func(string) error { return nil }, func(path string) error { + initSelfCmd(rootCmd, "v0.0.0-test", func(string) error { return nil }, func(path string) error { called = true gotPath = path @@ -288,6 +289,15 @@ func TestSelfCmd(t *testing.T) { if gotPath != expected { t.Fatalf("expected editor path %q, got %q", expected, gotPath) } + + info, err := os.Stat(filepath.Dir(expected)) + if err != nil { + t.Fatalf("expected config directory to exist: %v", err) + } + + if perm := info.Mode().Perm(); perm != 0o700 { + t.Fatalf("expected config directory permissions 0700, got %o", perm) + } }) t.Run("should open documentation in browser", func(t *testing.T) { @@ -306,7 +316,7 @@ func TestSelfCmd(t *testing.T) { rootCmd.SetArgs([]string{"self", "doc"}) rootCmd.SetOut(bufOut) rootCmd.SetErr(bufOut) - initSelfCmd(rootCmd, "v0.0.0-test", openURL) + initSelfCmd(rootCmd, "v0.0.0-test", openURL, func(string) error { return nil }) err := rootCmd.Execute() if err != nil { @@ -333,7 +343,7 @@ func TestSelfCmd(t *testing.T) { rootCmd.SetArgs([]string{"self", "doc"}) rootCmd.SetOut(bufOut) rootCmd.SetErr(bufOut) - initSelfCmd(rootCmd, "v0.0.0-test", openURL) + initSelfCmd(rootCmd, "v0.0.0-test", openURL, func(string) error { return nil }) err := rootCmd.Execute() if err == nil { diff --git a/internal/cmd/self.go b/internal/cmd/self.go index 3b39e509..6bf4c1f9 100644 --- a/internal/cmd/self.go +++ b/internal/cmd/self.go @@ -7,18 +7,11 @@ import ( // InitSelfCmd intializes root 'self' subcommand. func InitSelfCmd(rootCmd *cobra.Command, version string) { - initSelfCmd(rootCmd, version, util.OpenURL) + initSelfCmd(rootCmd, version, util.OpenURL, util.OpenEditor) } -func initSelfCmd(rootCmd *cobra.Command, version string, openURL func(string) error) { - initSelfCmdWithEditor(rootCmd, version, openURL, util.OpenEditor) -} - -func initSelfCmdWithEditor( - rootCmd *cobra.Command, - version string, - openURL func(string) error, - openEditor func(string) error, +func initSelfCmd( + rootCmd *cobra.Command, version string, openURL func(string) error, openEditor func(string) error, ) { selfCmd := &cobra.Command{ Use: "self", diff --git a/internal/cmd/self_config.go b/internal/cmd/self_config.go index 4c52caf6..f3b011bc 100644 --- a/internal/cmd/self_config.go +++ b/internal/cmd/self_config.go @@ -59,7 +59,7 @@ func initConfigEditCommand(openEditor func(string) error) *cobra.Command { return err } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("creating config directory: %w", err) }