mirror of
https://github.com/chenasraf/wand.git
synced 2026-05-18 01:38:59 +00:00
feat: add environment variables support (global and per-command)
This commit is contained in:
27
README.md
27
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
16
cmd/run.go
16
cmd/run.go
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user