Skip to main content

cli/env/
mod.rs

1pub mod broker;
2pub mod catalog;
3pub mod commands;
4pub mod identity;
5pub mod proxy;
6pub mod upgrade;
7pub mod workspace;
8
9use crate::config::Config;
10use anyhow::{Result, bail};
11use std::collections::{BTreeMap, BTreeSet};
12
13/// User-editable environment variables stored in `config.toml` under `[env]`.
14///
15/// Values are substituted into preset files that opt in via the `template`
16/// transform (using `@@VAR_NAME@@` placeholders).
17#[derive(Clone, Debug, Default)]
18pub struct EnvConfig {
19    vars: BTreeMap<String, String>,
20    descriptions: BTreeMap<String, String>,
21}
22
23impl EnvConfig {
24    pub fn from_config(config: &Config) -> Self {
25        Self {
26            vars: config.env.clone(),
27            descriptions: config.env_descriptions.clone(),
28        }
29    }
30
31    pub async fn load_or_init(config: &Config) -> Result<Self> {
32        Ok(Self::from_config(config))
33    }
34
35    pub fn get(&self, key: &str) -> Option<&str> {
36        self.vars.get(key).map(|s| s.as_str())
37    }
38
39    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
40        self.vars.insert(key.into(), value.into());
41    }
42
43    pub fn remove(&mut self, key: &str) -> Option<String> {
44        self.vars.remove(key)
45    }
46
47    pub fn as_map(&self) -> &BTreeMap<String, String> {
48        &self.vars
49    }
50
51    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
52        self.vars.iter().map(|(k, v)| (k.as_str(), v.as_str()))
53    }
54
55    pub fn description(&self, key: &str) -> Option<&str> {
56        self.descriptions.get(key).map(String::as_str)
57    }
58
59    pub async fn save(&self, config: &Config) -> Result<()> {
60        let mut updated = config.clone();
61        updated.env = self.vars.clone();
62        updated.save().await
63    }
64}
65
66impl From<EnvConfig> for BTreeMap<String, String> {
67    fn from(value: EnvConfig) -> Self {
68        value.vars
69    }
70}
71
72#[derive(Debug, PartialEq, Eq)]
73/// A config environment value selected using encrypted-first lookup.
74pub enum StoredValue<'a> {
75    Secret { key: String, value: &'a str },
76    Plaintext(&'a str),
77}
78
79/// Resolve `KEY_SECRET` first, falling back to plaintext `KEY`.
80pub fn resolve_stored_value<'a>(env: &'a EnvConfig, key: &str) -> Result<StoredValue<'a>> {
81    let secret_key = secret_key(key);
82    if let Some(value) = env.get(&secret_key) {
83        return Ok(StoredValue::Secret {
84            key: secret_key,
85            value,
86        });
87    }
88    if let Some(value) = env.get(key) {
89        return Ok(StoredValue::Plaintext(value));
90    }
91    bail!("{secret_key} or {key} is not set in the active config [env]");
92}
93
94/// Return the encrypted-storage key associated with an environment variable.
95pub fn secret_key(key: &str) -> String {
96    format!("{key}_SECRET")
97}
98
99/// A validated environment declaration using the `env run --with` grammar:
100/// resolve `source` from the active config and expose it under `target`.
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct EnvVarSpec {
103    pub source: String,
104    pub target: String,
105}
106
107impl EnvVarSpec {
108    /// The `--with` argument token that reproduces this declaration:
109    /// `KEY` when source and target match, otherwise `SOURCE=TARGET`.
110    pub fn to_with_arg(&self) -> String {
111        if self.source == self.target {
112            self.source.clone()
113        } else {
114            format!("{}={}", self.source, self.target)
115        }
116    }
117}
118
119/// Parse and validate an ordered list of `KEY` / `SOURCE_KEY=TARGET_KEY` specs
120/// (the shared grammar for `env run --with` and a Bun preset's `env` array).
121///
122/// Declaration order is preserved. Both names must be valid environment
123/// identifiers, and no two declarations may write the same target. Values are
124/// never resolved here — this is pure name validation, safe to run at metadata
125/// load time.
126pub(crate) fn parse_env_specs(specs: &[String]) -> Result<Vec<EnvVarSpec>> {
127    let mut parsed = Vec::with_capacity(specs.len());
128    let mut targets = BTreeSet::new();
129    for spec in specs {
130        let (source, target) = spec.split_once('=').unwrap_or((spec, spec));
131        validate_env_key(source)?;
132        validate_env_key(target)?;
133        if !targets.insert(target.to_string()) {
134            bail!("duplicate target variable: {target}");
135        }
136        parsed.push(EnvVarSpec {
137            source: source.to_string(),
138            target: target.to_string(),
139        });
140    }
141    Ok(parsed)
142}
143
144/// Validate an environment variable name: first character `[A-Za-z_]`, remaining
145/// characters `[A-Za-z0-9_]*`.
146pub(crate) fn validate_env_key(key: &str) -> Result<()> {
147    let mut chars = key.chars();
148    let Some(first) = chars.next() else {
149        bail!("environment variable name must not be empty");
150    };
151    if !(first == '_' || first.is_ascii_alphabetic())
152        || !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
153    {
154        bail!("invalid environment variable name: {key}");
155    }
156    Ok(())
157}
158
159impl EnvConfig {
160    #[cfg(test)]
161    fn with_defaults() -> Self {
162        Self {
163            vars: crate::config::default_env_map(),
164            descriptions: BTreeMap::new(),
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn from_config_reads_env_table() {
175        let dir = std::env::temp_dir().join(format!("shine-env-test-{}", uuid::Uuid::new_v4()));
176        let mut config = Config::new_for_test(&dir);
177        config.env.insert("HTTP_PROXY_PORT".into(), "7890".into());
178
179        let env = EnvConfig::from_config(&config);
180
181        assert_eq!(env.get("HTTP_PROXY_PORT"), Some("7890"));
182    }
183
184    #[test]
185    fn from_config_reads_description() {
186        let dir = std::env::temp_dir().join(format!("shine-env-test-{}", uuid::Uuid::new_v4()));
187        let mut config = Config::new_for_test(&dir);
188        config
189            .env_descriptions
190            .insert("MY_TOKEN".into(), "Internal token".into());
191
192        let env = EnvConfig::from_config(&config);
193
194        assert_eq!(env.description("MY_TOKEN"), Some("Internal token"));
195    }
196
197    #[test]
198    fn set_and_get_roundtrip() {
199        let mut env = EnvConfig::default();
200        env.set("MY_VAR", "hello");
201        assert_eq!(env.get("MY_VAR"), Some("hello"));
202        assert_eq!(env.get("OTHER"), None);
203    }
204
205    #[test]
206    fn remove_deletes_existing_key() {
207        let mut env = EnvConfig::default();
208        env.set("MY_VAR", "hello");
209
210        assert_eq!(env.remove("MY_VAR"), Some("hello".to_string()));
211        assert_eq!(env.get("MY_VAR"), None);
212    }
213
214    #[test]
215    fn remove_missing_key_returns_none() {
216        let mut env = EnvConfig::default();
217
218        assert_eq!(env.remove("OTHER"), None);
219    }
220
221    #[test]
222    fn as_map_reflects_all_vars() {
223        let mut env = EnvConfig::default();
224        env.set("A", "1");
225        env.set("B", "2");
226        let map = env.as_map();
227        assert_eq!(map.get("A").map(|s| s.as_str()), Some("1"));
228        assert_eq!(map.get("B").map(|s| s.as_str()), Some("2"));
229    }
230
231    #[test]
232    fn defaults_are_available_for_tests() {
233        let env = EnvConfig::with_defaults();
234        assert_eq!(env.get("HTTP_PROXY_PORT"), Some("6152"));
235        assert_eq!(env.get("SOCKS5_PROXY_PORT"), Some("6153"));
236        assert_eq!(env.get("PROXY_HOST"), Some("127.0.0.1"));
237        assert_eq!(env.get("PROXY_NO_PROXY"), Some("localhost,127.0.0.1,::1"));
238        assert_eq!(env.get("GHOSTTY_BG_LIGHT"), Some(""));
239        assert_eq!(env.get("GHOSTTY_BG_DARK"), Some(""));
240    }
241}