feat: add environment variables support (global and per-command)

This commit is contained in:
2026-03-30 19:33:06 +03:00
parent c1aaa67d42
commit 562d732b3a
5 changed files with 190 additions and 22 deletions

View File

@@ -19,6 +19,8 @@ project tree.
- **Positional arguments**: pass arguments to commands and reference them with `$1`, `$2`, `$@`.
- **Custom flags**: define typed flags (string or bool) with aliases, defaults, and descriptions,
accessible as `$WAND_FLAG_<NAME>` environment variables.
- **Environment variables**: define env vars globally in `.config` or per command, with command-level
overrides.
- **Built-in help**: auto-generated `--help` for every command and subcommand.
- **Shell execution**: runs commands via your `$SHELL` with proper stdin/stdout/stderr passthrough.
@@ -121,6 +123,7 @@ Each top-level key defines a command. The special key `main` becomes the root (n
| `cmd` | `string` | Shell command to execute |
| `children` | `map[string]Command` | Nested subcommands (same structure) |
| `flags` | `map[string]Flag` | Custom flags (see below) |
| `env` | `map[string]string` | Environment variables for this command |
### Flag fields
@@ -185,6 +188,30 @@ wand build
---
## 🌍 Environment Variables
Define environment variables globally in `.config` or per command. Command-level env vars override
global ones:
```yaml
.config:
env:
NODE_ENV: production
build:
description: build the project
cmd: echo "env=$NODE_ENV out=$OUTPUT_DIR"
env:
OUTPUT_DIR: ./dist
```
```bash
wand build
# → env=production out=./dist
```
---
## 🛠️ Contributing
I am developing this package on my free time, so any support, whether code, issues, or just stars is

View File

@@ -8,24 +8,27 @@ import (
"github.com/samber/lo"
"github.com/spf13/viper"
"go.yaml.in/yaml/v3"
)
type Flag struct {
Alias string `mapstructure:"alias"`
Description string `mapstructure:"description"`
Default interface{} `mapstructure:"default"`
Type string `mapstructure:"type"`
Alias string `yaml:"alias"`
Description string `yaml:"description"`
Default interface{} `yaml:"default"`
Type string `yaml:"type"`
}
type Command struct {
Description string `mapstructure:"description"`
Cmd string `mapstructure:"cmd"`
Children map[string]Command `mapstructure:"children"`
Flags map[string]Flag `mapstructure:"flags"`
Description string `yaml:"description"`
Cmd string `yaml:"cmd"`
Children map[string]Command `yaml:"children"`
Flags map[string]Flag `yaml:"flags"`
Env map[string]string `yaml:"env"`
}
type Config struct {
Shell interface{} `mapstructure:"shell"`
Shell interface{} `yaml:"shell"`
Env map[string]string `yaml:"env"`
}
func (c *Config) GetShell() string {
@@ -54,28 +57,34 @@ func runtimeOS() string {
}
}
type rawConfig struct {
DotConfig *Config `yaml:".config"`
Commands map[string]Command `yaml:",inline"`
}
func loadConfig() (*Config, map[string]Command, error) {
if err := initConfigPaths(); err != nil {
configPath, err := findConfigFile()
if err != nil {
return nil, nil, err
}
var cfg Config
if sub := viper.Sub(".config"); sub != nil {
if err := sub.Unmarshal(&cfg); err != nil {
return nil, nil, fmt.Errorf("failed to parse .config: %w", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read config: %w", err)
}
allEntries := make(map[string]Command)
if err := viper.Unmarshal(&allEntries); err != nil {
var raw rawConfig
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, nil, fmt.Errorf("failed to parse config: %w", err)
}
commands := lo.OmitByKeys(allEntries, []string{".config"})
cfg := lo.FromPtrOr(raw.DotConfig, Config{})
commands := lo.OmitByKeys(raw.Commands, []string{".config"})
return &cfg, commands, nil
}
func initConfigPaths() error {
func findConfigFile() (string, error) {
viper.SetConfigName("wand")
viper.SetConfigType("yaml")
@@ -103,8 +112,8 @@ func initConfigPaths() error {
}
if err := viper.ReadInConfig(); err != nil {
return fmt.Errorf("config file not found: %w", err)
return "", fmt.Errorf("config file not found: %w", err)
}
return nil
return viper.ConfigFileUsed(), nil
}

View File

@@ -228,6 +228,46 @@ main:
}
}
func TestLoadConfig_WithGlobalEnv(t *testing.T) {
setupTestConfig(t, `
.config:
env:
FOO: bar
main:
description: test
cmd: echo test
`)
cfg, _, err := loadConfig()
if err != nil {
t.Fatal(err)
}
if cfg.Env["FOO"] != "bar" {
t.Errorf("global env FOO = %q, want bar", cfg.Env["FOO"])
}
}
func TestLoadConfig_WithCommandEnv(t *testing.T) {
setupTestConfig(t, `
main:
description: test
cmd: echo test
env:
MY_VAR: hello
`)
_, commands, err := loadConfig()
if err != nil {
t.Fatal(err)
}
if commands["main"].Env["MY_VAR"] != "hello" {
t.Errorf("command env MY_VAR = %q, want hello", commands["main"].Env["MY_VAR"])
}
}
func TestLoadConfig_NoConfigFile(t *testing.T) {
dir := t.TempDir()
origDir, _ := os.Getwd()

View File

@@ -18,11 +18,25 @@ func runShellCmd(cfg *Config, command Command) func(*cobra.Command, []string) er
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), flagsToEnv(c, command.Flags)...)
cmd.Env = buildEnv(cfg, command, c)
return cmd.Run()
}
}
func buildEnv(cfg *Config, command Command, c *cobra.Command) []string {
env := os.Environ()
env = append(env, mapToEnvSlice(cfg.Env)...)
env = append(env, mapToEnvSlice(command.Env)...)
env = append(env, flagsToEnv(c, command.Flags)...)
return env
}
func mapToEnvSlice(m map[string]string) []string {
return lo.MapToSlice(m, func(k, v string) string {
return k + "=" + v
})
}
func flagsToEnv(c *cobra.Command, flags map[string]Flag) []string {
return lo.MapToSlice(flags, func(name string, flag Flag) string {
envKey := "WAND_FLAG_" + strings.ToUpper(name)

View File

@@ -124,6 +124,84 @@ func TestRunShellCmd_AllArgs(t *testing.T) {
}
}
func TestRunShellCmd_CommandEnv(t *testing.T) {
cfg := &Config{Shell: "sh"}
var buf bytes.Buffer
origStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
fn := runShellCmd(cfg, Command{
Cmd: "echo $MY_VAR",
Env: map[string]string{"MY_VAR": "hello"},
})
err := fn(nil, nil)
_ = w.Close()
os.Stdout = origStdout
_, _ = buf.ReadFrom(r)
if err != nil {
t.Fatalf("runShellCmd failed: %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "hello" {
t.Errorf("output = %q, want hello", got)
}
}
func TestRunShellCmd_GlobalEnv(t *testing.T) {
cfg := &Config{Shell: "sh", Env: map[string]string{"GLOBAL_VAR": "world"}}
var buf bytes.Buffer
origStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
fn := runShellCmd(cfg, Command{Cmd: "echo $GLOBAL_VAR"})
err := fn(nil, nil)
_ = w.Close()
os.Stdout = origStdout
_, _ = buf.ReadFrom(r)
if err != nil {
t.Fatalf("runShellCmd failed: %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "world" {
t.Errorf("output = %q, want world", got)
}
}
func TestRunShellCmd_CommandEnvOverridesGlobal(t *testing.T) {
cfg := &Config{Shell: "sh", Env: map[string]string{"MY_VAR": "global"}}
var buf bytes.Buffer
origStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
fn := runShellCmd(cfg, Command{
Cmd: "echo $MY_VAR",
Env: map[string]string{"MY_VAR": "local"},
})
err := fn(nil, nil)
_ = w.Close()
os.Stdout = origStdout
_, _ = buf.ReadFrom(r)
if err != nil {
t.Fatalf("runShellCmd failed: %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "local" {
t.Errorf("output = %q, want local (command env should override global)", got)
}
}
func TestRunShellCmd_InvalidShell(t *testing.T) {
cfg := &Config{Shell: "/nonexistent/shell"}