Skip to content

Commit b380dfe

Browse files
committed
feat: per-step env with $(…) shell interpolation
Add env field to bootstrap steps, hooks, and services with runtime shell interpolation. Values containing $(…) are evaluated via sh -c before the step runs. Step env merges on top of global env, failed commands produce empty string + warning.
1 parent 369b7e6 commit b380dfe

9 files changed

Lines changed: 131 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [v0.5.0](https://github.com/WarriorsCode/deck/releases/tag/v0.5.0) — 2026-04-03
6+
7+
### Added
8+
- Per-step `env` field on bootstrap steps, hooks, and services with `$(…)` shell interpolation
9+
- Env values containing `$(…)` are evaluated at runtime (not config load time) via `sh -c`
10+
- Step-level `env` merges on top of global `env` — step values win on conflict
11+
- Shell interpolation runs in the step's working directory for correct relative path resolution
12+
- Failed `$(…)` commands produce an empty string and log a warning; the step continues normally
13+
14+
### Changed
15+
- Introduced `config.Env` named type with `Merge` and `ToSlice` methods, replacing raw `map[string]string`
16+
- Service `env` values now support `$(…)` interpolation (previously only literal values)
17+
518
## [v0.4.0](https://github.com/WarriorsCode/deck/releases/tag/v0.4.0) — 2026-04-02
619

720
### Added

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ bootstrap:
6868
check: test -d node_modules
6969
run: pnpm install
7070

71+
- name: Create database
72+
env:
73+
PG_HOST: "$(sed -n '/^\[db\]/,/^\[/p' api/etc/app.conf | grep host | cut -d= -f2 | tr -d ' ')"
74+
PG_USER: "$(sed -n '/^\[db\]/,/^\[/p' api/etc/app.conf | grep user | cut -d= -f2 | tr -d ' ')"
75+
check: psql -h $PG_HOST -U $PG_USER -d myapp -c 'SELECT 1' 2>/dev/null
76+
run: createdb -h $PG_HOST -U $PG_USER myapp
77+
7178
- name: Set auth key
7279
check: "! grep -q \"AUTH_KEY=''\" .env"
7380
prompt: |
@@ -105,7 +112,7 @@ services:
105112
106113
| Field | Where | Description |
107114
|-------|-------|-------------|
108-
| `env` | top-level, service | Key-value env vars. Service-level merges on top of global. |
115+
| `env` | top-level, service, bootstrap, hook | Key-value env vars. Step-level merges on top of global. Supports `$(…)` shell interpolation. |
109116
| `env_file` | service, hook | Path to a dotenv file loaded before running. |
110117
| `dir` | service, bootstrap, hook | Working directory for the command. |
111118
| `check` | dep, bootstrap | Shell command — exit 0 means "already done, skip". |
@@ -137,7 +144,7 @@ deck up -f staging.yaml # no local merge
137144
## How It Works
138145

139146
1. **Deps** — checks each dependency, tries start strategies in order until check passes
140-
2. **Bootstrap** — runs setup steps if their check fails (idempotent), supports interactive prompts
147+
2. **Bootstrap** — runs setup steps if their check fails (idempotent), supports interactive prompts and `$(…)` env interpolation
141148
3. **Hooks** — pre-start hooks run before services, post-stop hooks run on shutdown
142149
4. **Services** — started as child processes, managed via PID files
143150
5. **Logs** — tailed with colored `[name]` prefixes, ANSI codes stripped, timestamps auto-detected

engine/bootstrap.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ 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-
resolved := ResolveEnv(step.Env, env)
21+
resolved := ResolveEnv(ctx, d, step.Env, env)
2222
stepEnv := MergeSlice(env, resolved)
2323
if CheckShell(ctx, d, step.Check, stepEnv) {
2424
continue

engine/bootstrap_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,82 @@ func TestReadMultiLineEmpty(t *testing.T) {
6868
assert.Equal(t, "", result)
6969
}
7070

71+
func TestBootstrapStepEnvLiteral(t *testing.T) {
72+
dir := t.TempDir()
73+
marker := filepath.Join(dir, "result")
74+
steps := []config.BootstrapStep{
75+
{Name: "Env test", Check: "false", Run: "echo $MY_VAR > " + marker, Env: map[string]string{"MY_VAR": "hello"}},
76+
}
77+
err := RunBootstrap(context.Background(), ".", steps, nil)
78+
require.NoError(t, err)
79+
data, err := os.ReadFile(marker)
80+
require.NoError(t, err)
81+
assert.Contains(t, string(data), "hello")
82+
}
83+
84+
func TestBootstrapStepEnvInterpolation(t *testing.T) {
85+
dir := t.TempDir()
86+
marker := filepath.Join(dir, "result")
87+
steps := []config.BootstrapStep{
88+
{Name: "Interpolate", Check: "false", Run: "echo $GREETING > " + marker, Env: map[string]string{"GREETING": "$(echo world)"}},
89+
}
90+
err := RunBootstrap(context.Background(), ".", steps, nil)
91+
require.NoError(t, err)
92+
data, err := os.ReadFile(marker)
93+
require.NoError(t, err)
94+
assert.Contains(t, string(data), "world")
95+
}
96+
97+
func TestBootstrapStepEnvAvailableInCheck(t *testing.T) {
98+
steps := []config.BootstrapStep{
99+
{Name: "Check sees env", Check: "test $MY_FLAG = yes", Run: "false", Env: map[string]string{"MY_FLAG": "yes"}},
100+
}
101+
// Check should pass thanks to env, so run (which would fail) is never called.
102+
err := RunBootstrap(context.Background(), ".", steps, nil)
103+
require.NoError(t, err)
104+
}
105+
106+
func TestBootstrapStepEnvOverridesGlobal(t *testing.T) {
107+
dir := t.TempDir()
108+
marker := filepath.Join(dir, "result")
109+
globalEnv := []string{"MY_VAR=global"}
110+
steps := []config.BootstrapStep{
111+
{Name: "Override", Check: "false", Run: "echo $MY_VAR > " + marker, Env: map[string]string{"MY_VAR": "step"}},
112+
}
113+
err := RunBootstrap(context.Background(), ".", steps, globalEnv)
114+
require.NoError(t, err)
115+
data, err := os.ReadFile(marker)
116+
require.NoError(t, err)
117+
assert.Contains(t, string(data), "step")
118+
}
119+
120+
func TestBootstrapStepEnvFailedInterpolation(t *testing.T) {
121+
dir := t.TempDir()
122+
marker := filepath.Join(dir, "result")
123+
steps := []config.BootstrapStep{
124+
{Name: "Bad cmd", Check: "false", Run: "echo [$MISSING] > " + marker, Env: map[string]string{"MISSING": "$(cat /nonexistent/xxx)"}},
125+
}
126+
err := RunBootstrap(context.Background(), ".", steps, nil)
127+
require.NoError(t, err)
128+
data, err := os.ReadFile(marker)
129+
require.NoError(t, err)
130+
assert.Contains(t, string(data), "[]")
131+
}
132+
133+
func TestBootstrapStepEnvNotVisibleToNextStep(t *testing.T) {
134+
dir := t.TempDir()
135+
marker := filepath.Join(dir, "result")
136+
steps := []config.BootstrapStep{
137+
{Name: "Step 1", Check: "false", Run: "true", Env: map[string]string{"STEP1_VAR": "secret"}},
138+
{Name: "Step 2", Check: "false", Run: "echo [$STEP1_VAR] > " + marker},
139+
}
140+
err := RunBootstrap(context.Background(), ".", steps, nil)
141+
require.NoError(t, err)
142+
data, err := os.ReadFile(marker)
143+
require.NoError(t, err)
144+
assert.Contains(t, string(data), "[]")
145+
}
146+
71147
func TestBootstrapPromptSkipsInNonTTY(t *testing.T) {
72148
steps := []config.BootstrapStep{
73149
{Name: "Needs input", Check: "false", Prompt: "Paste key:", Run: "true"},

engine/engine.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ func (e *Engine) Start() error {
5151
if err != nil {
5252
return fmt.Errorf("service %q: %w", name, err)
5353
}
54-
resolved := ResolveEnv(svc.Env, baseEnv)
54+
svcDir := stepDir(e.dir, svc.Dir)
55+
// context.Background: env resolution is a one-shot operation before process
56+
// spawn — no meaningful cancellation point like bootstrap/hooks have.
57+
resolved := ResolveEnv(context.Background(), svcDir, svc.Env, baseEnv)
5558
env := MergeSlice(baseEnv, resolved)
5659
if err := e.pm.Start(name, svc, env); err != nil {
5760
return err

engine/env.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package engine
22

33
import (
44
"bufio"
5+
"context"
56
"fmt"
67
"log/slog"
78
"os"
@@ -51,9 +52,11 @@ func MergeSlice(base []string, overlay config.Env) []string {
5152
}
5253

5354
// 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+
// Values containing $(…) are passed through `sh -c 'printf "%s" <value>'` to let
56+
// the shell handle all substitution (including nesting and quoting).
57+
// Literal values (no $(…)) are used as-is. If a command fails, the value is set
5558
// to empty string and a warning is logged.
56-
func ResolveEnv(raw config.Env, baseEnv []string) config.Env {
59+
func ResolveEnv(ctx context.Context, dir string, raw config.Env, baseEnv []string) config.Env {
5760
if len(raw) == 0 {
5861
return nil
5962
}
@@ -63,7 +66,8 @@ func ResolveEnv(raw config.Env, baseEnv []string) config.Env {
6366
resolved[k] = v
6467
continue
6568
}
66-
cmd := exec.Command("sh", "-c", "printf '%s' "+v)
69+
cmd := exec.CommandContext(ctx, "sh", "-c", `printf '%s' `+v)
70+
cmd.Dir = dir
6771
cmd.Env = baseEnv
6872
out, err := cmd.Output()
6973
if err != nil {

engine/env_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package engine
22

33
import (
4+
"context"
45
"os"
56
"path/filepath"
67
"testing"
@@ -88,23 +89,23 @@ func TestBuildEnvNilEverything(t *testing.T) {
8889
}
8990

9091
func TestResolveEnvLiteral(t *testing.T) {
91-
resolved := ResolveEnv(config.Env{"FOO": "bar", "BAZ": "qux"}, nil)
92+
resolved := ResolveEnv(context.Background(), ".", config.Env{"FOO": "bar", "BAZ": "qux"}, nil)
9293
assert.Equal(t, "bar", resolved["FOO"])
9394
assert.Equal(t, "qux", resolved["BAZ"])
9495
}
9596

9697
func TestResolveEnvInterpolation(t *testing.T) {
97-
resolved := ResolveEnv(config.Env{"GREETING": "$(echo hello)"}, nil)
98+
resolved := ResolveEnv(context.Background(), ".", config.Env{"GREETING": "$(echo hello)"}, nil)
9899
assert.Equal(t, "hello", resolved["GREETING"])
99100
}
100101

101102
func TestResolveEnvFailedCommand(t *testing.T) {
102-
resolved := ResolveEnv(config.Env{"MISSING": "$(cat /nonexistent/file/xxx)"}, nil)
103+
resolved := ResolveEnv(context.Background(), ".", config.Env{"MISSING": "$(cat /nonexistent/file/xxx)"}, nil)
103104
assert.Equal(t, "", resolved["MISSING"])
104105
}
105106

106107
func TestResolveEnvNil(t *testing.T) {
107-
assert.Nil(t, ResolveEnv(nil, nil))
108+
assert.Nil(t, ResolveEnv(context.Background(), ".", nil, nil))
108109
}
109110

110111
func TestMergeSlice(t *testing.T) {

engine/hooks.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
// If bestEffort is true, errors are logged but execution continues.
1212
// If bestEffort is false, first error stops execution.
1313
// globalEnv is the base env; each hook's EnvFile is layered on top.
14-
func RunHooks(ctx context.Context, dir string, hooks []config.Hook, bestEffort bool, globalEnv map[string]string) error {
14+
func RunHooks(ctx context.Context, dir string, hooks []config.Hook, bestEffort bool, globalEnv config.Env) error {
1515
for _, hook := range hooks {
1616
env, err := BuildEnv(globalEnv, hook.EnvFile, nil)
1717
if err != nil {
@@ -20,9 +20,9 @@ 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)
2523
d := stepDir(dir, hook.Dir)
24+
resolved := ResolveEnv(ctx, d, hook.Env, env)
25+
env = MergeSlice(env, resolved)
2626
if err := RunShell(ctx, d, hook.Run, env); err != nil {
2727
if bestEffort {
2828
continue

engine/hooks_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ func TestHooksBestEffort(t *testing.T) {
5151
require.NoError(t, err)
5252
}
5353

54+
func TestHooksWithStepEnv(t *testing.T) {
55+
dir := t.TempDir()
56+
marker := filepath.Join(dir, "result")
57+
hooks := []config.Hook{
58+
{Name: "Env hook", Run: "echo $HOOK_VAL > " + marker, Env: map[string]string{"HOOK_VAL": "$(echo interpolated)"}},
59+
}
60+
err := RunHooks(context.Background(), ".", hooks, false, nil)
61+
require.NoError(t, err)
62+
data, err := os.ReadFile(marker)
63+
require.NoError(t, err)
64+
require.Contains(t, string(data), "interpolated")
65+
}
66+
5467
func TestHooksWithEnvFile(t *testing.T) {
5568
dir := t.TempDir()
5669
envFile := filepath.Join(dir, "test.env")

0 commit comments

Comments
 (0)