Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/internal/config-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
95 changes: 95 additions & 0 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
@@ -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
}
133 changes: 133 additions & 0 deletions internal/cli/config_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading