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))