Skip to content

Commit 369b7e6

Browse files
committed
refactor: introduce config.Env type with Merge/ToSlice methods
1 parent 0fdfec5 commit 369b7e6

6 files changed

Lines changed: 146 additions & 62 deletions

File tree

config/config.go

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,37 @@ package config
22

33
import (
44
"fmt"
5+
"maps"
56
"os"
67
"path/filepath"
78

89
"gopkg.in/yaml.v3"
910
)
1011

12+
// Env is a string-to-string map of environment variables.
13+
type Env map[string]string
14+
15+
// Merge copies all entries from other into e. Existing keys are overwritten.
16+
func (e Env) Merge(other Env) {
17+
maps.Copy(e, other)
18+
}
19+
20+
// ToSlice converts to the []string format expected by exec.Cmd.Env.
21+
func (e Env) ToSlice() []string {
22+
s := make([]string, 0, len(e))
23+
for k, v := range e {
24+
s = append(s, k+"="+v)
25+
}
26+
return s
27+
}
28+
1129
type Config struct {
12-
Name string `yaml:"name"`
13-
Env map[string]string `yaml:"env"`
14-
Bootstrap []BootstrapStep `yaml:"bootstrap"`
15-
Deps Map[Dep] `yaml:"deps"`
16-
Hooks Hooks `yaml:"hooks"`
17-
Services Map[Service] `yaml:"services"`
30+
Name string `yaml:"name"`
31+
Env Env `yaml:"env"`
32+
Bootstrap []BootstrapStep `yaml:"bootstrap"`
33+
Deps Map[Dep] `yaml:"deps"`
34+
Hooks Hooks `yaml:"hooks"`
35+
Services Map[Service] `yaml:"services"`
1836
}
1937

2038
type BootstrapStep struct {
@@ -23,6 +41,7 @@ type BootstrapStep struct {
2341
Check string `yaml:"check"`
2442
Run string `yaml:"run"`
2543
Prompt string `yaml:"prompt"`
44+
Env Env `yaml:"env"`
2645
}
2746

2847
type Dep struct {
@@ -41,16 +60,17 @@ type Hook struct {
4160
Dir string `yaml:"dir"`
4261
Run string `yaml:"run"`
4362
EnvFile string `yaml:"env_file"`
63+
Env Env `yaml:"env"`
4464
}
4565

4666
type Service struct {
47-
Dir string `yaml:"dir"`
48-
Run string `yaml:"run"`
49-
Port int `yaml:"port"`
50-
Color string `yaml:"color"`
51-
Timestamp *bool `yaml:"timestamp"`
52-
Env map[string]string `yaml:"env"`
53-
EnvFile string `yaml:"env_file"`
67+
Dir string `yaml:"dir"`
68+
Run string `yaml:"run"`
69+
Port int `yaml:"port"`
70+
Color string `yaml:"color"`
71+
Timestamp *bool `yaml:"timestamp"`
72+
Env Env `yaml:"env"`
73+
EnvFile string `yaml:"env_file"`
5474
}
5575

5676
func (s Service) TimestampEnabled() bool {

engine/bootstrap.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,17 @@ import (
1818
func RunBootstrap(ctx context.Context, dir string, steps []config.BootstrapStep, env []string) error {
1919
for _, step := range steps {
2020
d := stepDir(dir, step.Dir)
21-
if CheckShell(ctx, d, step.Check, env) {
21+
resolved := ResolveEnv(step.Env, env)
22+
stepEnv := MergeSlice(env, resolved)
23+
if CheckShell(ctx, d, step.Check, stepEnv) {
2224
continue
2325
}
24-
stepEnv := env
2526
if step.Prompt != "" {
2627
extra, err := handlePrompt(step)
2728
if err != nil {
2829
return fmt.Errorf("bootstrap %q: %w", step.Name, err)
2930
}
30-
stepEnv = append(append([]string{}, env...), extra...)
31+
stepEnv = append(stepEnv, extra...)
3132
}
3233
if err := RunShell(ctx, d, step.Run, stepEnv); err != nil {
3334
return fmt.Errorf("bootstrap %q: %w", step.Name, err)

engine/engine.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,12 @@ func (e *Engine) Preflight(ctx context.Context) error {
4747
func (e *Engine) Start() error {
4848
var started []string
4949
err := e.cfg.Services.EachErr(func(name string, svc config.Service) error {
50-
env, err := BuildEnv(e.cfg.Env, svc.EnvFile, svc.Env)
50+
baseEnv, err := BuildEnv(e.cfg.Env, svc.EnvFile, nil)
5151
if err != nil {
5252
return fmt.Errorf("service %q: %w", name, err)
5353
}
54+
resolved := ResolveEnv(svc.Env, baseEnv)
55+
env := MergeSlice(baseEnv, resolved)
5456
if err := e.pm.Start(name, svc, env); err != nil {
5557
return err
5658
}

engine/env.go

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,60 +3,89 @@ package engine
33
import (
44
"bufio"
55
"fmt"
6+
"log/slog"
67
"os"
8+
"os/exec"
79
"strings"
10+
11+
"github.com/warriorscode/deck/config"
812
)
913

1014
// BuildEnv builds a combined environment from the OS env, global config env,
1115
// an optional env file, and per-step env overrides.
1216
// Precedence (highest wins): step env > env file > global env > OS env.
13-
func BuildEnv(globalEnv map[string]string, envFile string, stepEnv map[string]string) ([]string, error) {
14-
env := make(map[string]string, len(globalEnv)+len(stepEnv))
17+
func BuildEnv(globalEnv config.Env, envFile string, stepEnv config.Env) ([]string, error) {
18+
env := make(config.Env, len(globalEnv)+len(stepEnv))
1519

16-
// Start with OS environment.
1720
for _, e := range os.Environ() {
1821
k, v, _ := strings.Cut(e, "=")
1922
env[k] = v
2023
}
24+
env.Merge(globalEnv)
2125

22-
// Layer global config env.
23-
for k, v := range globalEnv {
24-
env[k] = v
25-
}
26-
27-
// Layer env file if specified.
2826
if envFile != "" {
2927
fileEnv, err := ParseEnvFile(envFile)
3028
if err != nil {
3129
return nil, err
3230
}
33-
for k, v := range fileEnv {
34-
env[k] = v
35-
}
31+
env.Merge(fileEnv)
3632
}
3733

38-
// Layer step-level env (highest priority).
39-
for k, v := range stepEnv {
40-
env[k] = v
34+
env.Merge(stepEnv)
35+
return env.ToSlice(), nil
36+
}
37+
38+
// MergeSlice overlays resolved env vars onto a base env slice.
39+
// Overlay values win on conflict.
40+
func MergeSlice(base []string, overlay config.Env) []string {
41+
if len(overlay) == 0 {
42+
return base
4143
}
44+
merged := make(config.Env, len(base)+len(overlay))
45+
for _, e := range base {
46+
k, v, _ := strings.Cut(e, "=")
47+
merged[k] = v
48+
}
49+
merged.Merge(overlay)
50+
return merged.ToSlice()
51+
}
4252

43-
result := make([]string, 0, len(env))
44-
for k, v := range env {
45-
result = append(result, k+"="+v)
53+
// ResolveEnv evaluates an env map, interpolating $(…) shell expressions.
54+
// Values without $(…) are used as-is. If a shell command fails, the value is set
55+
// to empty string and a warning is logged.
56+
func ResolveEnv(raw config.Env, baseEnv []string) config.Env {
57+
if len(raw) == 0 {
58+
return nil
59+
}
60+
resolved := make(config.Env, len(raw))
61+
for k, v := range raw {
62+
if !strings.Contains(v, "$(") {
63+
resolved[k] = v
64+
continue
65+
}
66+
cmd := exec.Command("sh", "-c", "printf '%s' "+v)
67+
cmd.Env = baseEnv
68+
out, err := cmd.Output()
69+
if err != nil {
70+
slog.Warn("env interpolation failed", "key", k, "error", err)
71+
resolved[k] = ""
72+
continue
73+
}
74+
resolved[k] = string(out)
4675
}
47-
return result, nil
76+
return resolved
4877
}
4978

5079
// ParseEnvFile reads a simple KEY=VALUE env file.
5180
// Supports comments (#), empty lines, and single/double quoted values.
52-
func ParseEnvFile(path string) (map[string]string, error) {
81+
func ParseEnvFile(path string) (config.Env, error) {
5382
f, err := os.Open(path)
5483
if err != nil {
5584
return nil, fmt.Errorf("opening env file %s: %w", path, err)
5685
}
5786
defer f.Close()
5887

59-
env := make(map[string]string)
88+
env := make(config.Env)
6089
scanner := bufio.NewScanner(f)
6190
for scanner.Scan() {
6291
line := strings.TrimSpace(scanner.Text())
@@ -69,7 +98,6 @@ func ParseEnvFile(path string) (map[string]string, error) {
6998
}
7099
k = strings.TrimSpace(k)
71100
v = strings.TrimSpace(v)
72-
// Strip surrounding quotes.
73101
if len(v) >= 2 && ((v[0] == '\'' && v[len(v)-1] == '\'') || (v[0] == '"' && v[len(v)-1] == '"')) {
74102
v = v[1 : len(v)-1]
75103
}

engine/env_test.go

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77

88
"github.com/stretchr/testify/assert"
99
"github.com/stretchr/testify/require"
10+
11+
"github.com/warriorscode/deck/config"
1012
)
1113

1214
func TestParseEnvFile(t *testing.T) {
@@ -41,53 +43,82 @@ func TestBuildEnvPrecedence(t *testing.T) {
4143
envFile := filepath.Join(dir, "test.env")
4244
require.NoError(t, os.WriteFile(envFile, []byte("A=from_file\nB=from_file\n"), 0644))
4345

44-
globalEnv := map[string]string{"A": "from_global", "B": "from_global", "C": "from_global"}
45-
stepEnv := map[string]string{"A": "from_step"}
46+
globalEnv := config.Env{"A": "from_global", "B": "from_global", "C": "from_global"}
47+
stepEnv := config.Env{"A": "from_step"}
4648

4749
result, err := BuildEnv(globalEnv, envFile, stepEnv)
4850
require.NoError(t, err)
4951

50-
envMap := make(map[string]string, len(result))
51-
for _, e := range result {
52-
k, v, _ := cutString(e, "=")
53-
envMap[k] = v
54-
}
55-
56-
// Step env wins over everything.
52+
envMap := toMap(result)
5753
assert.Equal(t, "from_step", envMap["A"])
58-
// Env file wins over global.
5954
assert.Equal(t, "from_file", envMap["B"])
60-
// Global fills in the rest.
6155
assert.Equal(t, "from_global", envMap["C"])
6256
}
6357

58+
func toMap(envSlice []string) map[string]string {
59+
m := make(map[string]string, len(envSlice))
60+
for _, e := range envSlice {
61+
if k, v, ok := cutString(e, "="); ok {
62+
m[k] = v
63+
}
64+
}
65+
return m
66+
}
67+
6468
func cutString(s, sep string) (string, string, bool) {
65-
i := 0
66-
for i < len(s) {
69+
for i := range len(s) {
6770
if s[i:i+len(sep)] == sep {
6871
return s[:i], s[i+len(sep):], true
6972
}
70-
i++
7173
}
7274
return s, "", false
7375
}
7476

7577
func TestBuildEnvNoFile(t *testing.T) {
76-
globalEnv := map[string]string{"FOO": "bar"}
78+
globalEnv := config.Env{"FOO": "bar"}
7779
result, err := BuildEnv(globalEnv, "", nil)
7880
require.NoError(t, err)
79-
80-
envMap := make(map[string]string, len(result))
81-
for _, e := range result {
82-
k, v, _ := cutString(e, "=")
83-
envMap[k] = v
84-
}
85-
assert.Equal(t, "bar", envMap["FOO"])
81+
assert.Equal(t, "bar", toMap(result)["FOO"])
8682
}
8783

8884
func TestBuildEnvNilEverything(t *testing.T) {
8985
result, err := BuildEnv(nil, "", nil)
9086
require.NoError(t, err)
91-
// Should at least have OS environment.
9287
assert.NotEmpty(t, result)
9388
}
89+
90+
func TestResolveEnvLiteral(t *testing.T) {
91+
resolved := ResolveEnv(config.Env{"FOO": "bar", "BAZ": "qux"}, nil)
92+
assert.Equal(t, "bar", resolved["FOO"])
93+
assert.Equal(t, "qux", resolved["BAZ"])
94+
}
95+
96+
func TestResolveEnvInterpolation(t *testing.T) {
97+
resolved := ResolveEnv(config.Env{"GREETING": "$(echo hello)"}, nil)
98+
assert.Equal(t, "hello", resolved["GREETING"])
99+
}
100+
101+
func TestResolveEnvFailedCommand(t *testing.T) {
102+
resolved := ResolveEnv(config.Env{"MISSING": "$(cat /nonexistent/file/xxx)"}, nil)
103+
assert.Equal(t, "", resolved["MISSING"])
104+
}
105+
106+
func TestResolveEnvNil(t *testing.T) {
107+
assert.Nil(t, ResolveEnv(nil, nil))
108+
}
109+
110+
func TestMergeSlice(t *testing.T) {
111+
base := []string{"A=1", "B=2"}
112+
step := config.Env{"B": "override", "C": "3"}
113+
merged := MergeSlice(base, step)
114+
115+
m := toMap(merged)
116+
assert.Equal(t, "1", m["A"])
117+
assert.Equal(t, "override", m["B"])
118+
assert.Equal(t, "3", m["C"])
119+
}
120+
121+
func TestMergeSliceEmpty(t *testing.T) {
122+
base := []string{"A=1"}
123+
assert.Equal(t, base, MergeSlice(base, nil))
124+
}

engine/hooks.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ func RunHooks(ctx context.Context, dir string, hooks []config.Hook, bestEffort b
2020
}
2121
return fmt.Errorf("hook %q: %w", hook.Name, err)
2222
}
23+
resolved := ResolveEnv(hook.Env, env)
24+
env = MergeSlice(env, resolved)
2325
d := stepDir(dir, hook.Dir)
2426
if err := RunShell(ctx, d, hook.Run, env); err != nil {
2527
if bestEffort {

0 commit comments

Comments
 (0)