From a517f58953e691db838f1dc5d8ecfd4c2bcb4a65 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Fri, 9 Oct 2026 15:45:23 +0800 Subject: [PATCH] feat(config): add init and view commands Add config initialization for global, local, and explicit paths without overwriting existing files or copying environment or flag credentials. Add safe config viewing with defaults, field-level api_key redaction, and no environment or API calls. Reject invalid selectors and positional inputs, and handle writer failures and short writes safely. Document command behavior and privacy assumptions with coverage for full gates, native smoke, and race tests. --- README.md | 13 +++ docs/internal/config-commands.md | 3 + internal/cli/config.go | 95 ++++++++++++++++++++++ internal/cli/config_test.go | 133 +++++++++++++++++++++++++++++++ internal/cli/root.go | 1 + 5 files changed, 245 insertions(+) create mode 100644 internal/cli/config.go create mode 100644 internal/cli/config_test.go diff --git a/README.md b/README.md index cf3845a..744bff2 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,19 @@ Precedence: **changed flags > nonblank environment > TOML > defaults**. - Trust the current directory before running decision/MCP commands: local settings can change the upstream destination. Environment/changed flags still win. Invalid, oversized (over 1 MiB), linked or nonregular local files fail without global fallback; help/version do not load them. - Start with [config.example.toml](config.example.toml). Prefer environment credentials rather than command-line secrets. +### Initialize and inspect + +```sh +jevwise config init # Global XDG/HOME config, even if local exists +jevwise config init --local # ./jevwise.toml, automatically selected in this cwd +jevwise config view # Selected file/defaults, API key redacted +jevwise config view --config /path/to/config.toml +``` + +- Init never overwrites existing files, directories or links. It writes a template, not your environment credentials; Unix file permissions are requested as `0600`, new parent directories as `0700`. Use a trusted parent with safe ownership/ACLs: mode bits alone do not guarantee privacy, including inherited Unix ACLs. Windows users must manage ACLs separately. +- `--config PATH` also selects an init destination; do not combine it with `--local` or pass a blank path. View ignores environment/decision flag overrides, unknown keys and comments; invalid files fail safely. It is not a dump of effective runtime settings. Only the `api_key` field is automatically redacted; do not store credentials in other displayed settings. +- Add `jevwise.toml` to your project's ignore rules before storing credentials. Prefer environment API keys. + ## Skill setup ```sh diff --git a/docs/internal/config-commands.md b/docs/internal/config-commands.md index 4a3c974..6ee6e2d 100644 --- a/docs/internal/config-commands.md +++ b/docs/internal/config-commands.md @@ -13,3 +13,6 @@ - Implementation split adjusted to four small branches so tests and documentation remain below the 300-line target; behavior unchanged. - 28 local QA passed: root fresh full gates and six builds; independent full tests and config race repeated 20 times. Initialization preserves existing entries and cleans staging; file view ignores environment and redacts only api_key. Mode bits are not ACL enforcement, so require trusted parent ownership/ACLs in public CLI docs. + +- 29 local QA passed after correcting blank selectors and short output writes: root fresh full gates, six builds and isolated native init/view smoke; independent full tests and CLI config race repeated 20 times. Public docs qualify ACL/privacy assumptions and field-only redaction. +- Follow-up outside this scope: shared Cobra help-template write errors may be printed directly to stderr; harden the shared HelpFunc separately, not just config commands. diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 0000000..a5ec855 --- /dev/null +++ b/internal/cli/config.go @@ -0,0 +1,95 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bitbrew-dev/jevwise/internal/config" + "github.com/spf13/cobra" +) + +func configNoArgs(_ *cobra.Command, args []string) error { + if len(args) != 0 { + return errors.New("config commands do not accept positional arguments") + } + return nil +} + +func configPathFlag(cmd *cobra.Command) (string, error) { + path, err := cmd.Flags().GetString("config") + if err != nil || cmd.Flags().Changed("config") && strings.TrimSpace(path) == "" { + return "", errors.New("configuration path must not be blank") + } + return path, nil +} + +func newConfig() *cobra.Command { + cmd := &cobra.Command{Use: "config", Short: "Manage TOML configuration", Args: configNoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }} + var local bool + init := &cobra.Command{Use: "init", Short: "Create configuration without replacing an existing file", Args: configNoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := cmd.Context().Err(); err != nil { + return err + } + path, err := configPathFlag(cmd) + if err != nil { + return err + } + if cmd.Flags().Changed("local") && !local || local && cmd.Flags().Changed("config") { + return errors.New("choose --local or --config, not both") + } + if local { + path, err = filepath.Abs(config.LocalName) + } else if path == "" { + path, err = config.GlobalPath() + } else { + path, err = filepath.Abs(path) + } + if err != nil { + return errors.New("cannot determine configuration path") + } + if err := config.Init(cmd.Context(), path); err != nil { + if errors.Is(err, os.ErrExist) { + return errors.New("configuration already exists; no file replaced") + } + return &decisionError{"cannot initialize configuration", err} + } + message := fmt.Sprintf("Created configuration: %q\n", path) + if n, err := fmt.Fprint(cmd.OutOrStdout(), message); err != nil || n != len(message) { + return errors.New("configuration created but output failed") + } + return nil + }} + init.Flags().BoolVar(&local, "local", false, "Create ./jevwise.toml instead of global configuration") + view := &cobra.Command{Use: "view", Short: "Show file settings with API key redacted (ignores environment)", Args: configNoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := cmd.Context().Err(); err != nil { + return err + } + path, err := configPathFlag(cmd) + if err != nil { + return err + } + path, _, err = config.ResolvePath(path) + if err != nil { + return err + } + text, err := config.View(path) + if err != nil { + return err + } + if err := cmd.Context().Err(); err != nil { + return err + } + if n, err := fmt.Fprint(cmd.OutOrStdout(), text); err != nil || n != len(text) { + return errors.New("cannot write configuration view") + } + return nil + }} + cmd.AddCommand(init, view) + return cmd +} diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go new file mode 100644 index 0000000..d02d74b --- /dev/null +++ b/internal/cli/config_test.go @@ -0,0 +1,133 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/bitbrew-dev/jevwise/internal/config" + "github.com/bitbrew-dev/jevwise/internal/service" + "github.com/spf13/cobra" +) + +func configEnvironment(t *testing.T) { + t.Helper() + decisionEnvironment(t) + t.Chdir(t.TempDir()) +} + +func configRoot(t *testing.T) *cobra.Command { + t.Helper() + return NewRootWithFactory(func(config.Config) (service.DecisionService, func(), error) { + t.Fatal("config command created decision service") + return nil, nil, nil + }) +} + +func TestConfigInitTargetsNoOverwriteAndAutoLoad(t *testing.T) { + for _, target := range []string{"global", "local", "explicit"} { + t.Run(target, func(t *testing.T) { + configEnvironment(t) + path, _ := config.GlobalPath() + args := []string{"config", "init", "--api-key=flag-secret"} + if target == "local" { + args = append(args, "--local") + path, _ = filepath.Abs(config.LocalName) + } else if target == "explicit" { + path = filepath.Join(t.TempDir(), "selected.toml") + args = append(args, "--config", path) + } else { + _ = os.WriteFile(config.LocalName, []byte("model='local'"), 0600) + } + out, stderr, err := execute(configRoot(t), args...) + data, readErr := os.ReadFile(path) + if err != nil || readErr != nil || stderr != "" || !strings.Contains(out, "Created configuration:") || strings.Contains(string(data)+out, "flag-secret") { + t.Fatal("init target or credential handling failed", err) + } + if out, _, err := execute(configRoot(t), args...); err == nil || out != "" { + t.Fatal("existing configuration overwritten") + } + viewArgs := []string{"config", "view"} + if target != "local" { + viewArgs = append(viewArgs, "--config", path) + } + if out, _, err := execute(configRoot(t), viewArgs...); err != nil || !strings.Contains(out, "[redacted]") { + t.Fatal("created file not viewable", err) + } + if target == "local" { + cfg, err := config.Load("", nil) + if err != nil || cfg.Model != "jev-latest" { + t.Fatal("local init not automatically loaded", err) + } + } + }) + } +} + +func TestConfigHelpValidationAndSafeView(t *testing.T) { + configEnvironment(t) + for _, args := range [][]string{{"config", "--help"}, {"config", "init", "--help"}, {"config", "view", "--help"}} { + out, stderr, err := execute(configRoot(t), append(args, "--config", "/missing")...) + if err != nil || stderr != "" || !strings.Contains(out, "jevwise config") { + t.Fatal("help loaded configuration", err) + } + } + for _, args := range [][]string{{"config", "private-secret"}, {"config", "init", "private-secret"}, {"config", "view", "private-secret"}, {"config", "init", "--local", "--config", "private-secret"}, {"config", "view", "--bad=private-secret"}, {"config", "view"}, {"config", "init", "--local", "--config="}, {"config", "init", "--config="}, {"config", "view", "--config="}} { + out, stderr, err := execute(configRoot(t), args...) + if err == nil || out != "" || stderr != "" || strings.Contains(err.Error(), "private-secret") { + t.Fatal("invalid config request accepted or leaked", err) + } + } + _ = os.WriteFile(config.LocalName, []byte("api_key='private-secret'\nmodel='file-model'"), 0600) + t.Setenv("TS_JEV_MODEL", "environment-model") + out, _, err := execute(configRoot(t), "config", "view", "--api-key=flag-secret") + if err != nil || strings.Contains(out, "secret") || strings.Contains(out, "environment-model") || !strings.Contains(out, "file-model") { + t.Fatal("view exposed credentials or effective overrides", err) + } + _ = os.WriteFile(config.LocalName, []byte("api_key='private-secret'\n["), 0600) + if out, _, err := execute(configRoot(t), "config", "view"); err == nil || out != "" || strings.Contains(err.Error(), "private-secret") { + t.Fatal("parser diagnostic leaked", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + cmd := configRoot(t) + cmd.SetArgs([]string{"config", "view"}) + var buffer bytes.Buffer + cmd.SetOut(&buffer) + if err := cmd.ExecuteContext(ctx); !errors.Is(err, context.Canceled) || buffer.Len() != 0 { + t.Fatal("cancelled view ran", err) + } +} + +func TestConfigOutputFailureDoesNotLeakOrUndoInit(t *testing.T) { + configEnvironment(t) + for _, operation := range []string{"init", "view"} { + cmd := configRoot(t) + cmd.SetArgs([]string{"config", operation}) + cmd.SetOut(secretIO{}) + err := cmd.ExecuteContext(context.Background()) + if err == nil || strings.Contains(err.Error(), "private-content") { + t.Fatal("writer error leaked", err) + } + path, _ := config.GlobalPath() + if _, err := os.Stat(path); err != nil { + t.Fatal("output failure undid initialization", err) + } + } +} + +func TestConfigShortOutputIsAnError(t *testing.T) { + configEnvironment(t) + for _, operation := range []string{"init", "view"} { + cmd := configRoot(t) + cmd.SetArgs([]string{"config", operation}) + cmd.SetOut(managementWriter(func(data []byte) (int, error) { return len(data) - 1, nil })) + if err := cmd.ExecuteContext(context.Background()); err == nil { + t.Fatal("short output accepted") + } + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index d23cdd1..c8a49a3 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -55,6 +55,7 @@ func NewRootWithFactory(factory ServiceFactory) *cobra.Command { flags.String("model", "jev-latest", "Jev model") flags.String("timeout", "10s", "Operation timeout, for example 10s") cmd.AddCommand(newVersion()) + cmd.AddCommand(newConfig()) releaseClient := update.NewClient(nil) cmd.AddCommand(newUpdate(updateOps{releaseClient.Latest, releaseClient.Download, update.Replace, runtime.GOOS, runtime.GOARCH}, buildinfo.Version)) cmd.AddCommand(newDecide(factory))