Skip to content

Commit 05cedb0

Browse files
feat: support global and node-level env variables (#108)
## Summary - Adds three-layer `env` inheritance (project → node → variant) to `veld.json`, matching the existing layering pattern used by `url_template`, `features`, and `client_log_levels` - For each key, the most specific layer wins; keys from parent layers that are not overridden are preserved - Adds `resolve_env()` in config.rs, updates all three `build_env` call sites in the orchestrator, extends the JSON schema with a shared `EnvMap` definition, and documents the feature ## Test plan - [x] 5 new unit tests for `resolve_env()` covering: no layers, project-only, node overrides project, variant overrides all, variant-only - [x] All 68 existing veld-core tests pass - [ ] Manual: create a veld.json with project-level `env`, verify vars are inherited by all variants - [ ] Manual: add node-level `env` override, verify node-level wins for that key while project keys are preserved - [ ] Manual: add variant-level `env` override, verify variant wins Closes #107 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4714e11 commit 05cedb0

8 files changed

Lines changed: 222 additions & 23 deletions

File tree

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,27 @@ Control which Veld capabilities are injected into `start_server` nodes' HTML res
198198
}
199199
```
200200

201-
Available features: `feedback_overlay` (toolbar/comments UI), `client_logs` (browser log collector). All default to `true`.
201+
Available features: `feedback_overlay` (toolbar/comments UI), `client_logs` (browser log collector), `inject` (auto-inject bootstrap scripts). All default to `true`.
202+
203+
### Environment variables
204+
205+
Declare `env` at the project, node, or variant level. Variables cascade: variant > node > project (per-key merge, most specific wins). Values support `${...}` variable substitution.
206+
207+
```json
208+
{
209+
"env": { "FEATURE_FLAG": "1" },
210+
"nodes": {
211+
"api": {
212+
"env": { "LOG_LEVEL": "debug" },
213+
"variants": {
214+
"local": {
215+
"env": { "PORT": "${veld.port}" }
216+
}
217+
}
218+
}
219+
}
220+
}
221+
```
202222

203223
### Variable interpolation
204224

crates/veld-core/src/config.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ pub struct VeldConfig {
6464
#[serde(default, skip_serializing_if = "Option::is_none")]
6565
pub features: Option<FeaturesConfig>,
6666

67+
/// Global environment variables inherited by all node variants.
68+
/// Overridable at node and variant level.
69+
#[serde(default, skip_serializing_if = "Option::is_none")]
70+
pub env: Option<HashMap<String, String>>,
71+
6772
/// The dependency graph nodes.
6873
pub nodes: HashMap<String, NodeConfig>,
6974
}
@@ -98,6 +103,11 @@ pub struct NodeConfig {
98103
#[serde(default, skip_serializing_if = "Option::is_none")]
99104
pub features: Option<FeaturesConfig>,
100105

106+
/// Extra environment variables inherited by all variants of this node.
107+
/// Overrides project-level env. Overridable at variant level.
108+
#[serde(default, skip_serializing_if = "Option::is_none")]
109+
pub env: Option<HashMap<String, String>>,
110+
101111
/// Working directory for all variants of this node. Relative paths are resolved from the project root (the directory containing veld.json).
102112
/// Overridable at variant level. Supports variable substitution.
103113
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -303,6 +313,29 @@ pub fn resolve_features(
303313
}
304314
}
305315

316+
/// Merge environment variable maps using the most specific override:
317+
/// variant > node > project. For each key, the most specific layer wins.
318+
pub fn resolve_env(
319+
project: Option<&HashMap<String, String>>,
320+
node: Option<&HashMap<String, String>>,
321+
variant: Option<&HashMap<String, String>>,
322+
) -> Option<HashMap<String, String>> {
323+
let layers: &[Option<&HashMap<String, String>>] = &[project, node, variant];
324+
let has_any = layers.iter().any(|l| l.is_some());
325+
if !has_any {
326+
return None;
327+
}
328+
329+
let mut merged = HashMap::new();
330+
// Apply from least specific to most specific so later layers override.
331+
for map in layers.iter().flatten() {
332+
for (k, v) in *map {
333+
merged.insert(k.clone(), v.clone());
334+
}
335+
}
336+
Some(merged)
337+
}
338+
306339
// ---------------------------------------------------------------------------
307340
// StepType enum
308341
// ---------------------------------------------------------------------------
@@ -650,6 +683,64 @@ mod tests {
650683
assert_eq!(result, PathBuf::from("/opt/services/api"));
651684
}
652685

686+
// -- Env resolution tests --------------------------------------------------
687+
688+
#[test]
689+
fn test_resolve_env_none() {
690+
assert_eq!(resolve_env(None, None, None), None);
691+
}
692+
693+
#[test]
694+
fn test_resolve_env_project_only() {
695+
let project = HashMap::from([("A".into(), "1".into())]);
696+
let result = resolve_env(Some(&project), None, None).unwrap();
697+
assert_eq!(result.get("A").unwrap(), "1");
698+
}
699+
700+
#[test]
701+
fn test_resolve_env_node_overrides_project() {
702+
let project = HashMap::from([("A".into(), "1".into()), ("B".into(), "2".into())]);
703+
let node = HashMap::from([("A".into(), "override".into())]);
704+
let result = resolve_env(Some(&project), Some(&node), None).unwrap();
705+
assert_eq!(result.get("A").unwrap(), "override");
706+
assert_eq!(result.get("B").unwrap(), "2");
707+
}
708+
709+
#[test]
710+
fn test_resolve_env_variant_overrides_all() {
711+
let project = HashMap::from([("A".into(), "1".into())]);
712+
let node = HashMap::from([("A".into(), "2".into()), ("B".into(), "3".into())]);
713+
let variant = HashMap::from([("A".into(), "final".into()), ("C".into(), "4".into())]);
714+
let result = resolve_env(Some(&project), Some(&node), Some(&variant)).unwrap();
715+
assert_eq!(result.get("A").unwrap(), "final");
716+
assert_eq!(result.get("B").unwrap(), "3");
717+
assert_eq!(result.get("C").unwrap(), "4");
718+
}
719+
720+
#[test]
721+
fn test_resolve_env_empty_map_with_values() {
722+
let empty = HashMap::new();
723+
let variant = HashMap::from([("X".into(), "1".into())]);
724+
let result = resolve_env(Some(&empty), None, Some(&variant)).unwrap();
725+
assert_eq!(result.len(), 1);
726+
assert_eq!(result.get("X").unwrap(), "1");
727+
}
728+
729+
#[test]
730+
fn test_resolve_env_all_empty_maps() {
731+
let empty = HashMap::new();
732+
let result = resolve_env(Some(&empty), Some(&empty), Some(&empty)).unwrap();
733+
assert!(result.is_empty());
734+
}
735+
736+
#[test]
737+
fn test_resolve_env_variant_only() {
738+
let variant = HashMap::from([("X".into(), "val".into())]);
739+
let result = resolve_env(None, None, Some(&variant)).unwrap();
740+
assert_eq!(result.len(), 1);
741+
assert_eq!(result.get("X").unwrap(), "val");
742+
}
743+
653744
#[test]
654745
fn test_resolve_cwd_variant_none_falls_through_to_node() {
655746
let root = PathBuf::from("/projects/myapp");

crates/veld-core/src/graph.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,14 +309,27 @@ fn validate_variable_references(
309309
.push(&sel.variant);
310310
}
311311

312-
// For each node, scan its env and command strings for unqualified refs.
312+
// Scan project-level env for unqualified refs.
313+
if let Some(env_map) = &config.env {
314+
for v in env_map.values() {
315+
check_string_for_ambiguous_refs(v, &active_variants)?;
316+
}
317+
}
318+
319+
// For each node, scan its env, variant env, and command strings for unqualified refs.
313320
for sel in all_nodes {
314-
let variant_cfg = &config.nodes[&sel.node].variants[&sel.variant];
321+
let node_cfg = &config.nodes[&sel.node];
322+
let variant_cfg = &node_cfg.variants[&sel.variant];
315323

316324
let mut strings_to_check: Vec<&str> = Vec::new();
317325
if let Some(cmd) = &variant_cfg.command {
318326
strings_to_check.push(cmd);
319327
}
328+
if let Some(env_map) = &node_cfg.env {
329+
for v in env_map.values() {
330+
strings_to_check.push(v);
331+
}
332+
}
320333
if let Some(env_map) = &variant_cfg.env {
321334
for v in env_map.values() {
322335
strings_to_check.push(v);

crates/veld-core/src/orchestrator.rs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -829,8 +829,14 @@ impl Orchestrator {
829829
}
830830
};
831831

832-
// Build env from the variant config.
833-
let env = match build_env(variant_cfg.env.as_ref(), &ctx) {
832+
// Build env (variant > node > project).
833+
let node_cfg_opt = self.config.nodes.get(&node_state.node_name);
834+
let merged_env = config::resolve_env(
835+
self.config.env.as_ref(),
836+
node_cfg_opt.and_then(|n| n.env.as_ref()),
837+
variant_cfg.env.as_ref(),
838+
);
839+
let env = match build_env(merged_env.as_ref(), &ctx) {
834840
Ok(env) => env,
835841
Err(e) => {
836842
tracing::warn!(
@@ -843,7 +849,6 @@ impl Orchestrator {
843849
};
844850

845851
// Resolve working directory (variant > node > project root).
846-
let node_cfg_opt = self.config.nodes.get(&node_state.node_name);
847852
let working_dir = resolve_working_dir(
848853
variant_cfg.cwd.as_deref(),
849854
node_cfg_opt.and_then(|n| n.cwd.as_deref()),
@@ -1221,8 +1226,13 @@ async fn execute_start_server_isolated(
12211226
)
12221227
.await;
12231228

1224-
// Build env.
1225-
let mut env = build_env(variant_cfg.env.as_ref(), var_ctx)?;
1229+
// Build env (variant > node > project).
1230+
let merged_env = config::resolve_env(
1231+
ctx.config.env.as_ref(),
1232+
node_cfg.env.as_ref(),
1233+
variant_cfg.env.as_ref(),
1234+
);
1235+
let mut env = build_env(merged_env.as_ref(), var_ctx)?;
12261236
env.insert("VELD_PORT".to_owned(), port.to_string());
12271237
env.insert("VELD_URL".to_owned(), https_url.clone());
12281238

@@ -1489,7 +1499,13 @@ async fn execute_command_isolated(
14891499
};
14901500
let resolved_cmd = crate::variables::interpolate(&raw_cmd, var_ctx)?;
14911501

1492-
let env = build_env(variant_cfg.env.as_ref(), var_ctx)?;
1502+
// Build env (variant > node > project).
1503+
let merged_env = config::resolve_env(
1504+
ctx.config.env.as_ref(),
1505+
node_cfg.env.as_ref(),
1506+
variant_cfg.env.as_ref(),
1507+
);
1508+
let env = build_env(merged_env.as_ref(), var_ctx)?;
14931509

14941510
// Verify step (idempotency).
14951511
if let Some(ref verify_cmd) = variant_cfg.verify {

docs/configuration.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ All relative paths in the configuration resolve relative to the directory contai
4040
| `presets` | object | No | Named shortcuts for node:variant selections |
4141
| `client_log_levels` | array | No | Browser log levels to capture (see [Client-Side Log Levels]) |
4242
| `features` | object | No | Feature toggles (see [Features](#features)) |
43+
| `env` | object | No | Global environment variables inherited by all nodes |
4344
| `nodes` | object | Yes | The dependency graph nodes |
4445

4546
[Client-Side Log Levels]: #client-side-log-levels
@@ -118,6 +119,7 @@ Controls which Veld capabilities are injected into `start_server` nodes' HTML re
118119
|---------------------|---------|---------|-------------|
119120
| `feedback_overlay` | boolean | `true` | Inject the feedback overlay toolbar (FAB, screenshot, comments) |
120121
| `client_logs` | boolean | `true` | Inject the client-side log collector |
122+
| `inject` | boolean | `true` | Auto-inject bootstrap scripts into HTML responses. When `false`, `/__veld__/*` routes are still available for manual `<script>` tags. |
121123

122124
```json
123125
{
@@ -139,6 +141,35 @@ Controls which Veld capabilities are injected into `start_server` nodes' HTML re
139141

140142
In this example, the project disables the feedback overlay by default, and the `api` node also disables client logs. But the `api:local` variant re-enables the feedback overlay.
141143

144+
### `env`
145+
146+
Global environment variables inherited by all node variants. Values support Veld variable substitution. The same override hierarchy applies: variant > node > project. For each key, the most specific layer wins; keys from parent layers that are not overridden are preserved.
147+
148+
```json
149+
{
150+
"env": {
151+
"FEATURE_FLAG_X": "1",
152+
"SHARED_CONFIG": "value"
153+
},
154+
"nodes": {
155+
"api": {
156+
"env": {
157+
"SHARED_CONFIG": "api-override"
158+
},
159+
"variants": {
160+
"local": {
161+
"env": {
162+
"PORT": "${veld.port}"
163+
}
164+
}
165+
}
166+
}
167+
}
168+
}
169+
```
170+
171+
In this example, `api:local` inherits `FEATURE_FLAG_X=1` from the project, gets `SHARED_CONFIG=api-override` from the node (overriding the project value), and adds `PORT` at the variant level.
172+
142173
---
143174

144175
## Nodes
@@ -391,7 +422,9 @@ Extra environment variables injected into the process. Values support Veld varia
391422
}
392423
```
393424

394-
**Precedence:** The `env` block takes strict precedence over the inherited shell environment. Shell variables not overridden by `env` are passed through unchanged.
425+
**Layering:** Environment variables cascade from project to node to variant. For each key, the most specific layer wins. Keys from parent layers that are not overridden are preserved. See the project-level [`env`](#env) section for a full example.
426+
427+
**Precedence:** The merged `env` block takes strict precedence over the inherited shell environment. Shell variables not overridden by `env` are passed through unchanged.
395428

396429
### `outputs`
397430

schema/v1/veld.schema.json

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@
5050
"$ref": "#/$defs/FeaturesConfig",
5151
"description": "Feature toggles (project-level defaults). Overridable at node and variant level."
5252
},
53+
"env": {
54+
"$ref": "#/$defs/EnvMap",
55+
"description": "Global environment variables inherited by all node variants. Overridable at node and variant level."
56+
},
5357
"nodes": {
5458
"type": "object",
5559
"description": "The dependency graph nodes. Each key is a node name, and the value defines its variants.",
@@ -87,6 +91,10 @@
8791
"$ref": "#/$defs/FeaturesConfig",
8892
"description": "Feature toggles override for all variants of this node."
8993
},
94+
"env": {
95+
"$ref": "#/$defs/EnvMap",
96+
"description": "Extra environment variables inherited by all variants of this node. Overrides project-level env. Overridable at variant level."
97+
},
9098
"cwd": {
9199
"type": "string",
92100
"description": "Working directory for all variants of this node. Relative paths are resolved from the project root. Overridable at variant level. Supports Veld variable substitution."
@@ -132,11 +140,8 @@
132140
}
133141
},
134142
"env": {
135-
"type": "object",
136-
"description": "Extra environment variables injected into the process.",
137-
"additionalProperties": {
138-
"type": "string"
139-
}
143+
"$ref": "#/$defs/EnvMap",
144+
"description": "Extra environment variables injected into the process. Overrides node-level and project-level env."
140145
},
141146
"outputs": {
142147
"description": "Output declarations. For command steps: an array of output names captured from $VELD_OUTPUT_FILE (preferred) or VELD_OUTPUT stdout lines (legacy). For start_server steps: an object mapping output names to template strings.",
@@ -250,6 +255,13 @@
250255
"default": ["log", "warn", "error"],
251256
"uniqueItems": true
252257
},
258+
"EnvMap": {
259+
"type": "object",
260+
"description": "A map of environment variable names to values. Values support Veld variable substitution (e.g. ${veld.port}, ${nodes.backend.url}).",
261+
"additionalProperties": {
262+
"type": "string"
263+
}
264+
},
253265
"HealthCheck": {
254266
"type": "object",
255267
"description": "Health check configuration for a start_server variant.",

skills/veld/reference/config.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,10 @@ Named shortcuts for common selections:
118118

119119
| Field | Level | Description |
120120
|-------|-------|-------------|
121+
| `env` | project, node, variant | Environment variables. Cascades: variant > node > project (per-key merge). Supports `${...}` substitution. |
121122
| `cwd` | node, variant | Working directory. Relative paths resolve from project root. Variant overrides node. Supports `${...}` substitution. |
122123
| `hidden` | node | Hide from `veld nodes` output |
123124
| `client_log_levels` | project, node, variant | Browser log levels: `["log", "warn", "error", "info", "debug"]`. Exceptions always captured. |
124-
| `features` | project, node, variant | `{"feedback_overlay": bool, "client_logs": bool}`. All default `true`. |
125+
| `features` | project, node, variant | `{"feedback_overlay": bool, "client_logs": bool, "inject": bool}`. All default `true`. |
125126
| `on_stop` | variant | Teardown command that runs on `veld stop`. |
126127
| `sensitive_outputs` | variant | Output keys to mask in logs and encrypt at rest. |

0 commit comments

Comments
 (0)