Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/biulight_shine.json
T

1 line
310 KiB
JSON

{"repo": "biulight/shine", "files": {"presets/env.toml": {"c2852a0e6ff23c2b2279d4b9a0bf503b316bca6a": {"t": 1785745323.239737, "n": 1865, "h": "24f4546b8e15353a", "c": "[[variables]]\nkey = \"HTTP_PROXY_PORT\"\ndescription = \"Local HTTP proxy port\"\n\n[[variables]]\nkey = \"SOCKS5_PROXY_PORT\"\ndescription = \"Local SOCKS5 proxy port\"\n\n[[variables]]\nkey = \"PROXY_HOST\"\ndescription = \"Proxy server host\"\n\n[[variables]]\nkey = \"PROXY_NO_PROXY\"\ndescription = \"Hosts that bypass the proxy\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_LIGHT\"\ndescription = \"Light-mode Ghostty background image\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_DARK\"\ndescription = \"Dark-mode Ghostty background image\"\n\n[[variables]]\nkey = \"SURGE_PROFILE\"\ndescription = \"Active Surge profile patched by shine app artifact apply/remove surge\"\n\n[[variables]]\nkey = \"SURGE_SUBSCRIPTION_URL\"\ndescription = \"HTTPS Base64 proxy subscription used to generate Surge policies\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY\"\ndescription = \"DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY_SECRET\"\ndescription = \"Encrypted DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY_GPG_SECRET\"\ndescription = \"Legacy GPG-encrypted DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY\"\ndescription = \"Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY_SECRET\"\ndescription = \"Encrypted Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY_GPG_SECRET\"\ndescription = \"Legacy GPG-encrypted Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"CLIPROXYAPI_AUTH_TOKEN\"\ndescription = \"CLIProxyAPI client authentication token\"\nsensitive = true\n\n[[variables]]\nkey = \"CLIPROXYAPI_AUTH_TOKEN_SECRET\"\ndescription = \"Encrypted CLIProxyAPI client authentication token\"\nsensitive = true\n\n[[variables]]\nkey = \"CLIPROXYAPI_AUTH_TOKEN_GPG_SECRET\"\ndescription = \"Legacy GPG-encrypted CLIProxyAPI client authentication token\"\nsensitive = true\n"}, "d587fde751efc8fa282e7fecb60f28173e623416": {"t": 1785750186.3111808, "n": 1865, "h": "24f4546b8e15353a", "c": "[[variables]]\nkey = \"HTTP_PROXY_PORT\"\ndescription = \"Local HTTP proxy port\"\n\n[[variables]]\nkey = \"SOCKS5_PROXY_PORT\"\ndescription = \"Local SOCKS5 proxy port\"\n\n[[variables]]\nkey = \"PROXY_HOST\"\ndescription = \"Proxy server host\"\n\n[[variables]]\nkey = \"PROXY_NO_PROXY\"\ndescription = \"Hosts that bypass the proxy\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_LIGHT\"\ndescription = \"Light-mode Ghostty background image\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_DARK\"\ndescription = \"Dark-mode Ghostty background image\"\n\n[[variables]]\nkey = \"SURGE_PROFILE\"\ndescription = \"Active Surge profile patched by shine app artifact apply/remove surge\"\n\n[[variables]]\nkey = \"SURGE_SUBSCRIPTION_URL\"\ndescription = \"HTTPS Base64 proxy subscription used to generate Surge policies\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY\"\ndescription = \"DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY_SECRET\"\ndescription = \"Encrypted DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY_GPG_SECRET\"\ndescription = \"Legacy GPG-encrypted DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY\"\ndescription = \"Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY_SECRET\"\ndescription = \"Encrypted Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY_GPG_SECRET\"\ndescription = \"Legacy GPG-encrypted Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"CLIPROXYAPI_AUTH_TOKEN\"\ndescription = \"CLIProxyAPI client authentication token\"\nsensitive = true\n\n[[variables]]\nkey = \"CLIPROXYAPI_AUTH_TOKEN_SECRET\"\ndescription = \"Encrypted CLIProxyAPI client authentication token\"\nsensitive = true\n\n[[variables]]\nkey = \"CLIPROXYAPI_AUTH_TOKEN_GPG_SECRET\"\ndescription = \"Legacy GPG-encrypted CLIProxyAPI client authentication token\"\nsensitive = true\n"}, "213286603d36ee9b3879dbd98664b04311ece16a": {"t": 1785750188.4068565, "n": 1857, "h": "15131abd18f8c888", "c": "[[variables]]\nkey = \"HTTP_PROXY_PORT\"\ndescription = \"Local HTTP proxy port\"\n\n[[variables]]\nkey = \"SOCKS5_PROXY_PORT\"\ndescription = \"Local SOCKS5 proxy port\"\n\n[[variables]]\nkey = \"PROXY_HOST\"\ndescription = \"Proxy server host\"\n\n[[variables]]\nkey = \"PROXY_NO_PROXY\"\ndescription = \"Hosts that bypass the proxy\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_LIGHT\"\ndescription = \"Light-mode Ghostty background image\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_DARK\"\ndescription = \"Dark-mode Ghostty background image\"\n\n[[variables]]\nkey = \"SURGE_PROFILE\"\ndescription = \"Active Surge profile patched by shine app build/unbuild surge\"\n\n[[variables]]\nkey = \"SURGE_SUBSCRIPTION_URL\"\ndescription = \"HTTPS Base64 proxy subscription used to generate Surge policies\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY\"\ndescription = \"DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY_SECRET\"\ndescription = \"Encrypted DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY_GPG_SECRET\"\ndescription = \"Legacy GPG-encrypted DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY\"\ndescription = \"Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY_SECRET\"\ndescription = \"Encrypted Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY_GPG_SECRET\"\ndescription = \"Legacy GPG-encrypted Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"CLIPROXYAPI_AUTH_TOKEN\"\ndescription = \"CLIProxyAPI client authentication token\"\nsensitive = true\n\n[[variables]]\nkey = \"CLIPROXYAPI_AUTH_TOKEN_SECRET\"\ndescription = \"Encrypted CLIProxyAPI client authentication token\"\nsensitive = true\n\n[[variables]]\nkey = \"CLIPROXYAPI_AUTH_TOKEN_GPG_SECRET\"\ndescription = \"Legacy GPG-encrypted CLIProxyAPI client authentication token\"\nsensitive = true\n"}, "6472f5f75b1e84e9f9ed79e8098b4c2f56d25da0": {"t": 1785750189.5077536, "n": 1464, "h": "5a028ed2fb278463", "c": "[[variables]]\nkey = \"HTTP_PROXY_PORT\"\ndescription = \"Local HTTP proxy port\"\n\n[[variables]]\nkey = \"SOCKS5_PROXY_PORT\"\ndescription = \"Local SOCKS5 proxy port\"\n\n[[variables]]\nkey = \"PROXY_HOST\"\ndescription = \"Proxy server host\"\n\n[[variables]]\nkey = \"PROXY_NO_PROXY\"\ndescription = \"Hosts that bypass the proxy\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_LIGHT\"\ndescription = \"Light-mode Ghostty background image\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_DARK\"\ndescription = \"Dark-mode Ghostty background image\"\n\n[[variables]]\nkey = \"SURGE_PROFILE\"\ndescription = \"Active Surge profile patched by shine app build/unbuild surge\"\n\n[[variables]]\nkey = \"SURGE_SUBSCRIPTION_URL\"\ndescription = \"HTTPS Base64 proxy subscription used to generate Surge policies\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY\"\ndescription = \"DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY_GPG_SECRET\"\ndescription = \"GPG-encrypted DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY\"\ndescription = \"Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY_GPG_SECRET\"\ndescription = \"GPG-encrypted Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"CLIPROXYAPI_AUTH_TOKEN\"\ndescription = \"CLIProxyAPI client authentication token\"\nsensitive = true\n\n[[variables]]\nkey = \"CLIPROXYAPI_AUTH_TOKEN_GPG_SECRET\"\ndescription = \"GPG-encrypted CLIProxyAPI client authentication token\"\nsensitive = true\n"}, "bc5f71a1242e9548b4d53492e363295e59f01b3a": {"t": 1785750190.5824096, "n": 1201, "h": "a99485925af29cea", "c": "[[variables]]\nkey = \"HTTP_PROXY_PORT\"\ndescription = \"Local HTTP proxy port\"\n\n[[variables]]\nkey = \"SOCKS5_PROXY_PORT\"\ndescription = \"Local SOCKS5 proxy port\"\n\n[[variables]]\nkey = \"PROXY_HOST\"\ndescription = \"Proxy server host\"\n\n[[variables]]\nkey = \"PROXY_NO_PROXY\"\ndescription = \"Hosts that bypass the proxy\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_LIGHT\"\ndescription = \"Light-mode Ghostty background image\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_DARK\"\ndescription = \"Dark-mode Ghostty background image\"\n\n[[variables]]\nkey = \"SURGE_PROFILE\"\ndescription = \"Active Surge profile patched by shine app build/unbuild surge\"\n\n[[variables]]\nkey = \"SURGE_SUBSCRIPTION_URL\"\ndescription = \"HTTPS Base64 proxy subscription used to generate Surge policies\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY\"\ndescription = \"DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY_GPG_SECRET\"\ndescription = \"GPG-encrypted DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY\"\ndescription = \"Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY_GPG_SECRET\"\ndescription = \"GPG-encrypted Qwen API authentication key\"\nsensitive = true\n"}, "3dbe905546c51210c2d0fb1053f8e563468e906c": {"t": 1785750191.8049974, "n": 943, "h": "9b516289cf612d98", "c": "[[variables]]\nkey = \"HTTP_PROXY_PORT\"\ndescription = \"Local HTTP proxy port\"\n\n[[variables]]\nkey = \"SOCKS5_PROXY_PORT\"\ndescription = \"Local SOCKS5 proxy port\"\n\n[[variables]]\nkey = \"PROXY_HOST\"\ndescription = \"Proxy server host\"\n\n[[variables]]\nkey = \"PROXY_NO_PROXY\"\ndescription = \"Hosts that bypass the proxy\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_LIGHT\"\ndescription = \"Light-mode Ghostty background image\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_DARK\"\ndescription = \"Dark-mode Ghostty background image\"\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY\"\ndescription = \"DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY_GPG_SECRET\"\ndescription = \"GPG-encrypted DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY\"\ndescription = \"Qwen API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"QWEN_API_KEY_GPG_SECRET\"\ndescription = \"GPG-encrypted Qwen API authentication key\"\nsensitive = true\n"}, "7617b80c2e3b02380d511ef9e8d7c161d9374b8f": {"t": 1785750192.8957508, "n": 724, "h": "551854ff68adbe45", "c": "[[variables]]\nkey = \"HTTP_PROXY_PORT\"\ndescription = \"Local HTTP proxy port\"\n\n[[variables]]\nkey = \"SOCKS5_PROXY_PORT\"\ndescription = \"Local SOCKS5 proxy port\"\n\n[[variables]]\nkey = \"PROXY_HOST\"\ndescription = \"Proxy server host\"\n\n[[variables]]\nkey = \"PROXY_NO_PROXY\"\ndescription = \"Hosts that bypass the proxy\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_LIGHT\"\ndescription = \"Light-mode Ghostty background image\"\n\n[[variables]]\nkey = \"GHOSTTY_BG_DARK\"\ndescription = \"Dark-mode Ghostty background image\"\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY\"\ndescription = \"DeepSeek API authentication key\"\nsensitive = true\n\n[[variables]]\nkey = \"DEEPSEEK_API_KEY_GPG_SECRET\"\ndescription = \"GPG-encrypted DeepSeek API authentication key\"\nsensitive = true\n"}}, "cli/src/env/commands.rs": {"c2852a0e6ff23c2b2279d4b9a0bf503b316bca6a": {"t": 1785745856.2003543, "n": 41545, "h": "deab3cf27e77ae76", "c": "//! Handlers for `shine env list/set/delete/get/decrypt/export/encrypt`.\n\nuse anyhow::{Context, Result, bail};\n\nuse super::{EnvConfig, StoredValue, resolve_stored_value, secret_key};\nuse crate::config::{Config, EnvOverrideKind, EnvOverrideSource};\nuse crate::secret::{BackendKind, EncryptRecipients};\nuse crate::{colors, path_display, secret, shells};\n\n/// Which layer supplied a variable's effective value, used to group the\n/// `env list` output. `Config` is the `config.toml [env]` table (global or\n/// project, deliberately not distinguished); the rest are `shine.env.toml`\n/// override files. Ordering matches display order (`config.toml` first, then\n/// override layers low-to-high by precedence).\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\nenum EnvSourceGroup {\n Config,\n Global,\n Overlay { managed: bool },\n Project,\n}\n\nimpl EnvSourceGroup {\n /// Fixed display order. Lower sorts first.\n fn order(self) -> u8 {\n match self {\n EnvSourceGroup::Config => 0,\n EnvSourceGroup::Global => 1,\n EnvSourceGroup::Overlay { .. } => 2,\n EnvSourceGroup::Project => 3,\n }\n }\n\n /// The bold header label for this section.\n fn label(self) -> &'static str {\n match self {\n EnvSourceGroup::Config => \"config.toml\",\n EnvSourceGroup::Global => \"global env file\",\n EnvSourceGroup::Overlay { managed: false } => \"overlay\",\n EnvSourceGroup::Overlay { managed: true } => \"overlay (managed)\",\n EnvSourceGroup::Project => \"project env file\",\n }\n }\n}\n\n/// Classify a key by its override source: no override → `config.toml [env]`,\n/// otherwise the override file's layer (folding `is_managed_overlay` into the\n/// `Overlay` variant).\nfn env_source_group(source: Option<&EnvOverrideSource>) -> EnvSourceGroup {\n match source {\n None => EnvSourceGroup::Config,\n Some(source) => match source.kind {\n EnvOverrideKind::Global => EnvSourceGroup::Global,\n EnvOverrideKind::Overlay => EnvSourceGroup::Overlay {\n managed: source.is_managed_overlay,\n },\n EnvOverrideKind::Project => EnvSourceGroup::Project,\n },\n }\n}\n\n/// Partition `keys` into ordered, non-empty sections by source group,\n/// preserving each key's relative order within its group. Pure over a\n/// `source_of` lookup so it can be unit-tested without terminal/config I/O.\nfn group_env_keys<'a>(\n keys: impl Iterator<Item = &'a str>,\n source_of: impl Fn(&str) -> Option<&'a EnvOverrideSource>,\n) -> Vec<(EnvSourceGroup, Vec<&'a str>)> {\n let mut groups: Vec<(EnvSourceGroup, Vec<&'a str>)> = Vec::new();\n for key in keys {\n let group = env_source_group(source_of(key));\n match groups.iter_mut().find(|(g, _)| *g == group) {\n Some((_, members)) => members.push(key),\n None => groups.push((group, vec![key])),\n }\n }\n groups.sort_by_key(|(group, _)| group.order());\n groups\n}\n\npub async fn handle_list(config: &Config, reveal: bool) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let catalog = super::catalog::load(config).await?;\n let terminal_width = usize::from(console::Term::stdout().size().1).max(40);\n let key_width = env\n .iter()\n .map(|(key, _)| key.chars().count())\n .max()\n .unwrap_or(0);\n\n println!(\"{}\", colors::bold(\"Environment\"));\n println!();\n if env.as_map().is_empty() {\n println!(\" {}\", colors::dim(\"No variables configured.\"));\n println!();\n }\n\n let groups = group_env_keys(env.iter().map(|(k, _)| k), |key| {\n config.env_override_source(key)\n });\n for (group, keys) in groups {\n // Header: bold label, plus the override file's path for non-config groups.\n match keys.first().and_then(|key| config.env_override_source(key)) {\n Some(source) => println!(\n \"{} {}\",\n colors::bold(group.label()),\n colors::dim(&path_display::format(&source.path))\n ),\n None => println!(\"{}\", colors::bold(group.label())),\n }\n for k in keys {\n let v = env.get(k).unwrap_or_default();\n let metadata = catalog.get(k);\n let description = env\n .description(k)\n .or_else(|| metadata.map(|item| item.description.as_str()))\n .unwrap_or_default();\n let sensitive = metadata.is_some_and(|item| item.sensitive) || is_sensitive_env_key(k);\n let display_value = display_env_value(v, sensitive, reveal);\n let (display_value, description) =\n fit_env_row(&display_value, description, key_width, terminal_width);\n let key_padding = \" \".repeat(key_width.saturating_sub(k.chars().count()));\n if description.is_empty() {\n println!(\" {}{} {}\", colors::cyan(k), key_padding, display_value);\n } else {\n println!(\n \" {}{} {:<value_width$} {}\",\n colors::cyan(k),\n key_padding,\n display_value,\n colors::dim(&description),\n value_width = env_value_width(key_width, terminal_width),\n );\n }\n }\n println!();\n }\n\n println!(\n \" {} {}\",\n colors::dim(\"Config\"),\n colors::dim(&path_display::format(config.config_path()))\n );\n println!(\n \" {}\",\n colors::dim(&format!(\"{} variables\", env.as_map().len()))\n );\n Ok(())\n}\n\nfn is_sensitive_env_key(key: &str) -> bool {\n let key = key.to_ascii_uppercase();\n [\n \"SECRET\",\n \"TOKEN\",\n \"PASSWORD\",\n \"PASSPHRASE\",\n \"API_KEY\",\n \"PRIVATE_KEY\",\n \"ACCESS_KEY\",\n \"SUBSCRIPTION_URL\",\n ]\n .iter()\n .any(|suffix| key == *suffix || key.ends_with(&format!(\"_{suffix}\")))\n}\n\nfn display_env_value(value: &str, sensitive: bool, reveal: bool) -> String {\n if value.is_empty() {\n \"<empty>\".to_string()\n } else if sensitive && !reveal {\n \"<redacted>\".to_string()\n } else {\n value.to_string()\n }\n}\n\nfn env_value_width(key_width: usize, terminal_width: usize) -> usize {\n terminal_width.saturating_sub(key_width + 28).clamp(12, 36)\n}\n\nfn fit_env_row(\n value: &str,\n description: &str,\n key_width: usize,\n terminal_width: usize,\n) -> (String, String) {\n let value_width = env_value_width(key_width, terminal_width);\n let value = truncate_text(value, value_width);\n let description_width = terminal_width.saturating_sub(2 + key_width + 2 + value_width + 2);\n let description = if description_width < 8 {\n String::new()\n } else {\n truncate_text(description, description_width)\n };\n (value, description)\n}\n\nfn truncate_text(value: &str, max_width: usize) -> String {\n if value.chars().count() <= max_width {\n return value.to_string();\n }\n if max_width <= 1 {\n return \"…\".to_string();\n }\n let mut result = value.chars().take(max_width - 1).collect::<String>();\n result.push('…');\n result\n}\n\n/// Where an `env set`/`encrypt`/`delete` write should land: `config.toml [env]`\n/// (the default, unshadowed case), or a specific override file that already\n/// supplies the key's effective value.\n#[derive(Debug)]\nenum EnvWriteTarget<'a> {\n ConfigToml,\n OverrideFile(&'a crate::config::EnvOverrideSource),\n}\n\n/// Decide where a write to `key` should go. Refuses (unless `force`) when an\n/// override file already shadows `config.toml [env]` for this key, since a\n/// plain write there would silently have no effect on the resolved value. With\n/// `force`, warns loudly when the winning file is the shine-managed overlay\n/// mirror, since that write will be discarded on the next `shine preset pull`.\nfn resolve_env_write_target<'a>(\n config: &'a Config,\n key: &str,\n force: bool,\n) -> Result<EnvWriteTarget<'a>> {\n let Some(source) = config.env_override_source(key) else {\n return Ok(EnvWriteTarget::ConfigToml);\n };\n if !force {\n bail!(\n \"{key} currently resolves from {} (an env override file), which takes precedence over {}; this write would have no effect.\\nRe-run with --force to write directly into that file instead.\",\n path_display::format(&source.path),\n path_display::format(config.config_path()),\n );\n }\n if source.is_managed_overlay {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\n \"Warning: {} is the shine-managed overlay mirror; this change will be discarded on the next `shine preset pull`/`shine update`. Edit it upstream on the maintaining device instead.\",\n path_display::format(&source.path)\n ))\n );\n }\n Ok(EnvWriteTarget::OverrideFile(source))\n}\n\npub async fn handle_set(config: &Config, key: &str, value: &str, force: bool) -> Result<()> {\n let catalog = super::catalog::load(config).await?;\n let sensitive =\n catalog.get(key).is_some_and(|item| item.sensitive) || is_sensitive_env_key(key);\n let display_value = display_env_value(value, sensitive, false);\n match resolve_env_write_target(config, key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(key, value);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{display_value}\\\" in {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, key, Some(value)).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{display_value}\\\" in {}\",\n path_display::format(&source.path)\n ))\n );\n }\n }\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_delete(config: &Config, key: &str, force: bool) -> Result<()> {\n if !config.env.contains_key(key) && config.env_override_source(key).is_none() {\n bail!(\"{key} is not set in the active config [env]\");\n }\n match resolve_env_write_target(config, key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.remove(key);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"deleted {key} from {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, key, None).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"deleted {key} from {}\",\n path_display::format(&source.path)\n ))\n );\n }\n }\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_get(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n match env.get(key) {\n Some(v) => println!(\"{v}\"),\n None => {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\"{key} is not set in the active config [env]\"))\n );\n std::process::exit(1);\n }\n }\n Ok(())\n}\n\npub async fn handle_decrypt(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n let plaintext = secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {key}\"))?;\n print!(\"{plaintext}\");\n Ok(())\n}\n\npub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {\n validate_env_export_key(key)?;\n if let Some(alias) = alias {\n validate_env_export_key(alias)?;\n }\n let env = EnvConfig::load_or_init(config).await?;\n let value = match resolve_env_export_value(&env, key)? {\n EnvExportValue::Secret {\n key: secret_key,\n value,\n } => secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {secret_key}\"))?,\n EnvExportValue::Plaintext(value) => value.to_string(),\n };\n let export_as = alias.unwrap_or(key);\n println!(\n \"{}\",\n format_env_export(&config.shell_type, export_as, &value)\n );\n Ok(())\n}\n\ntype EnvExportValue<'a> = StoredValue<'a>;\n\nfn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {\n resolve_stored_value(env, key)\n}\n\nfn env_export_secret_key(key: &str) -> String {\n secret_key(key)\n}\n\nfn validate_env_export_key(key: &str) -> Result<()> {\n let mut chars = key.chars();\n let Some(first) = chars.next() else {\n bail!(\"env secret export key must not be empty\");\n };\n if !(first == '_' || first.is_ascii_alphabetic()) {\n bail!(\"env secret export key must start with a letter or underscore: {key}\");\n }\n if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {\n bail!(\"env secret export key must contain only letters, digits, and underscores: {key}\");\n }\n Ok(())\n}\n\n/// `pub(crate)` so `theme::handle_sync` can reuse the same per-shell quoting\n/// instead of adding a fourth `single_quote` implementation to the codebase\n/// (see docs/terminal-theme-sync-prd.md §7/§10).\npub(crate) fn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {\n match shell {\n shells::ShellType::Fish => format!(\"set -gx {key} {}\", fish_quote(value)),\n shells::ShellType::PowerShell => {\n format!(\"$env:{key} = {}\", powershell_string_quote(value))\n }\n _ => format!(\"export {key}={}\", posix_shell_quote(value)),\n }\n}\n\nfn posix_shell_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"'\\\\''\"))\n}\n\nfn fish_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\\\', \"\\\\\\\\\").replace('\\'', \"\\\\'\"))\n}\n\nfn powershell_string_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"''\"))\n}\n\n#[derive(Debug, PartialEq, Eq)]\nenum EnvEncryptOutput {\n Print,\n Set(String),\n}\n\nfn resolve_env_encrypt_output(\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<EnvEncryptOutput> {\n if let Some(key) = set_key {\n return Ok(EnvEncryptOutput::Set(key.to_string()));\n }\n if let Some(key) = from_key {\n validate_env_export_key(key)?;\n return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));\n }\n Ok(EnvEncryptOutput::Print)\n}\n\nfn resolve_encrypt_backend(config: &Config, backend: Option<&str>) -> Result<BackendKind> {\n if let Some(backend) = backend.map(str::trim).filter(|value| !value.is_empty()) {\n return backend.parse();\n }\n if let Some(backend) = config\n .secret_backend\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n {\n return backend.parse();\n }\n Ok(BackendKind::default())\n}\n\nfn clean_recipients(recipients: &[String]) -> Vec<String> {\n recipients\n .iter()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .collect()\n}\n\nfn resolve_encrypt_recipients(\n backend: BackendKind,\n cli_recipients: &[String],\n config: &Config,\n) -> Result<EncryptRecipients> {\n let cli_recipients = clean_recipients(cli_recipients);\n if !cli_recipients.is_empty() {\n if backend == BackendKind::Gpg\n && let Some(hint) = cli_recipients\n .iter()\n .find(|value| value.starts_with(\"age1\"))\n {\n bail!(\"recipient \\\"{hint}\\\" looks like an age recipient; did you mean --backend age?\");\n }\n return Ok(match backend {\n BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),\n BackendKind::Age => EncryptRecipients::Age(cli_recipients),\n });\n }\n\n match backend {\n BackendKind::Gpg => {\n let recipient = config\n .gpg_key_id\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .context(\n \"GPG recipient is required; pass -r/--recipient, set gpg_key_id, or set secret_backend/age_recipients for age\",\n )?;\n Ok(EncryptRecipients::Gpg(vec![recipient.to_string()]))\n }\n BackendKind::Age => {\n let recipients = clean_recipients(&config.age_recipients);\n if recipients.is_empty() {\n bail!(\n \"age recipients are required; pass -r/--recipient or set age_recipients in config.toml\"\n );\n }\n Ok(EncryptRecipients::Age(recipients))\n }\n }\n}\n\npub async fn handle_encrypt(\n config: &Config,\n backend: Option<&str>,\n recipients: &[String],\n set_key: Option<&str>,\n from_key: Option<&str>,\n force: bool,\n) -> Result<()> {\n use std::io::Read as _;\n\n let backend = resolve_encrypt_backend(config, backend)?;\n let recipients = resolve_encrypt_recipients(backend, recipients, config)?;\n let plaintext = if let Some(key) = from_key {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n value.as_bytes().to_vec()\n } else {\n let mut input = Vec::new();\n std::io::stdin()\n .read_to_end(&mut input)\n .context(\"reading secret from stdin\")?;\n input\n };\n let encoded = secret::encrypt_secret(&plaintext, &recipients)\n .await\n .context(\"encrypting secret\")?;\n match resolve_env_encrypt_output(set_key, from_key)? {\n EnvEncryptOutput::Set(key) => match resolve_env_write_target(config, &key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(&key, &encoded);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{encoded}\\\" in {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, &key, Some(&encoded)).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{encoded}\\\" in {}\",\n path_display::format(&source.path)\n ))\n );\n }\n },\n EnvEncryptOutput::Print => println!(\"{encoded}\"),\n }\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::config::Config;\n use tokio::fs;\n\n async fn make_temp_dir() -> std::path::PathBuf {\n crate::test_support::make_temp_dir(\"shine-env-cmd-test\").await\n }\n\n fn config_in(dir: &std::path::Path) -> Config {\n crate::test_support::test_config(dir)\n }\n\n #[test]\n fn env_show_redacts_sensitive_values() {\n assert_eq!(display_env_value(\"secret\", true, false), \"<redacted>\");\n assert_eq!(display_env_value(\"secret\", true, true), \"secret\");\n assert_eq!(display_env_value(\"\", true, false), \"<empty>\");\n assert!(is_sensitive_env_key(\"MY_API_KEY\"));\n assert!(is_sensitive_env_key(\"token\"));\n assert!(is_sensitive_env_key(\"SURGE_SUBSCRIPTION_URL\"));\n assert!(!is_sensitive_env_key(\"MONKEY\"));\n }\n\n fn source(kind: EnvOverrideKind, managed: bool) -> EnvOverrideSource {\n EnvOverrideSource {\n path: std::path::PathBuf::from(\"/tmp/shine.env.toml\"),\n kind,\n is_managed_overlay: managed,\n }\n }\n\n #[test]\n fn env_source_group_maps_each_layer() {\n assert_eq!(env_source_group(None), EnvSourceGroup::Config);\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Global, false))),\n EnvSourceGroup::Global\n );\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Overlay, false))),\n EnvSourceGroup::Overlay { managed: false }\n );\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Overlay, true))),\n EnvSourceGroup::Overlay { managed: true }\n );\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Project, false))),\n EnvSourceGroup::Project\n );\n }\n\n #[test]\n fn group_env_keys_orders_sections_and_skips_empty() {\n let global = source(EnvOverrideKind::Global, false);\n let overlay = source(EnvOverrideKind::Overlay, true);\n // Keys deliberately out of source order; config keys have no override.\n let keys = [\"PROJECT_LESS\", \"FROM_OVERLAY\", \"FROM_CONFIG\", \"FROM_GLOBAL\"];\n let groups = group_env_keys(keys.iter().copied(), |key| match key {\n \"FROM_GLOBAL\" => Some(&global),\n \"FROM_OVERLAY\" => Some(&overlay),\n _ => None,\n });\n\n // Only Config, Global, Overlay are present (Project skipped), in order.\n assert_eq!(\n groups.iter().map(|(g, _)| *g).collect::<Vec<_>>(),\n vec![\n EnvSourceGroup::Config,\n EnvSourceGroup::Global,\n EnvSourceGroup::Overlay { managed: true },\n ]\n );\n assert_eq!(groups[0].1, vec![\"PROJECT_LESS\", \"FROM_CONFIG\"]);\n assert_eq!(groups[1].1, vec![\"FROM_GLOBAL\"]);\n assert_eq!(groups[2].1, vec![\"FROM_OVERLAY\"]);\n }\n\n #[test]\n fn group_env_keys_all_config_yields_single_group() {\n let keys = [\"A\", \"B\", \"C\"];\n let groups = group_env_keys(keys.iter().copied(), |_| None);\n assert_eq!(groups.len(), 1);\n assert_eq!(groups[0].0, EnvSourceGroup::Config);\n assert_eq!(groups[0].1, vec![\"A\", \"B\", \"C\"]);\n }\n\n #[test]\n fn env_source_group_labels_are_stable() {\n assert_eq!(EnvSourceGroup::Config.label(), \"config.toml\");\n assert_eq!(EnvSourceGroup::Global.label(), \"global env file\");\n assert_eq!(\n EnvSourceGroup::Overlay { managed: false }.label(),\n \"overlay\"\n );\n assert_eq!(\n EnvSourceGroup::Overlay { managed: true }.label(),\n \"overlay (managed)\"\n );\n assert_eq!(EnvSourceGroup::Project.label(), \"project env file\");\n }\n\n #[test]\n fn env_show_truncates_long_values_to_requested_width() {\n assert_eq!(truncate_text(\"abcdefgh\", 5), \"abcd…\");\n let (value, description) = fit_env_row(\n \"abcdefghijklmnopqrstuvwxyz\",\n \"A description that is also fairly long\",\n 8,\n 48,\n );\n assert!(value.chars().count() <= env_value_width(8, 48));\n assert!(description.chars().count() <= 48);\n }\n\n #[test]\n fn env_export_uses_alias_as_variable_name() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"MY_ALIAS\", value),\n \"export MY_ALIAS='secret123'\"\n );\n }\n\n #[test]\n fn env_export_alias_formats_powershell_correctly() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"MY_ALIAS\", value),\n \"$env:MY_ALIAS = 'secret123'\"\n );\n }\n\n #[tokio::test]\n async fn env_delete_removes_key_from_saved_config() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n config.env.insert(\"MY_TOKEN\".into(), \"secret\".into());\n config.save().await.unwrap();\n\n handle_delete(&config, \"MY_TOKEN\", false).await.unwrap();\n\n let contents = fs::read_to_string(config.config_path()).await.unwrap();\n let parsed: toml::Table = toml::from_str(&contents).unwrap();\n let env = parsed\n .get(\"env\")\n .and_then(|value| value.as_table())\n .unwrap();\n assert!(\n !env.contains_key(\"MY_TOKEN\"),\n \"deleted key should not remain in saved config: {contents}\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_fails_when_key_is_missing() {\n let dir = make_temp_dir().await;\n let config = config_in(&dir);\n\n let err = handle_delete(&config, \"MY_TOKEN\", false).await.unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN is not set in the active config [env]\"),\n \"error should explain missing key: {err:#}\"\n );\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[test]\n fn env_export_secret_key_appends_secret_suffix() {\n assert_eq!(\n env_export_secret_key(\"DEEPSEEK_API_KEY\"),\n \"DEEPSEEK_API_KEY_SECRET\"\n );\n assert_eq!(env_export_secret_key(\"xxx\"), \"xxx_SECRET\");\n }\n\n #[test]\n fn env_export_resolves_secret_when_present() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_falls_back_to_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Plaintext(\"plain\")\n );\n }\n\n #[test]\n fn env_export_secret_wins_over_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_reports_both_missing_keys() {\n let env = EnvConfig::default();\n\n let err = resolve_env_export_value(&env, \"MY_TOKEN\").unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]\"),\n \"error should explain both checked keys: {err:#}\"\n );\n }\n\n #[test]\n fn env_export_key_validation_accepts_shell_variable_names() {\n for key in [\"FOO\", \"_FOO\", \"foo_123\", \"A1\"] {\n validate_env_export_key(key).unwrap();\n }\n }\n\n #[test]\n fn env_export_key_validation_rejects_unsafe_names() {\n for key in [\"\", \"1FOO\", \"FOO-BAR\", \"FOO;BAR\", \"FOO BAR\", \"FOO.SECRET\"] {\n assert!(\n validate_env_export_key(key).is_err(),\n \"key should be rejected: {key}\"\n );\n }\n }\n\n #[test]\n fn env_export_formats_posix_shell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"TOKEN\", value),\n \"export TOKEN='abc def'\\\\''ghi$HOME\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_fish_shell_code_safely() {\n let value = \"abc def'ghi\\\\path\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Fish, \"TOKEN\", value),\n \"set -gx TOKEN 'abc def\\\\'ghi\\\\\\\\path\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_powershell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; Remove-Item /\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"TOKEN\", value),\n \"$env:TOKEN = 'abc def''ghi$HOME\\nnext; Remove-Item /'\"\n );\n }\n\n #[test]\n fn env_encrypt_output_defaults_from_key_to_secret_key() {\n assert_eq!(\n resolve_env_encrypt_output(None, Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"GH_TOKEN_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_explicit_set_wins_over_default() {\n assert_eq!(\n resolve_env_encrypt_output(Some(\"CUSTOM_SECRET\"), Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"CUSTOM_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_prints_stdin_without_set() {\n assert_eq!(\n resolve_env_encrypt_output(None, None).unwrap(),\n EnvEncryptOutput::Print\n );\n }\n\n #[test]\n fn env_encrypt_output_rejects_invalid_inferred_from_key() {\n let err = resolve_env_encrypt_output(None, Some(\"GH-TOKEN\")).unwrap_err();\n\n assert!(\n err.to_string().contains(\n \"env secret export key must contain only letters, digits, and underscores\"\n ),\n \"error should explain invalid inferred key: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_backend_cli_wins_over_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, Some(\"gpg\")).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_backend_falls_back_to_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Age\n );\n }\n\n #[test]\n fn encrypt_backend_defaults_to_gpg() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_recipients_cli_wins_over_config_for_gpg() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"cli@example.com\".to_string()], &config)\n .unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"cli@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"config@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_treats_empty_config_as_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\" \".to_string());\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_gpg_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_age_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.age_recipients = vec![\"age1qexample\".to_string()];\n\n let recipients = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Age(values) => assert_eq!(values, vec![\"age1qexample\"]),\n EncryptRecipients::Gpg(_) => panic!(\"expected age recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_age_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap_err();\n\n assert!(\n err.to_string().contains(\"age recipients are required\"),\n \"error should explain how to set age recipients: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_hints_when_age_recipient_used_with_gpg_backend() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"age1qexample\".to_string()], &config)\n .unwrap_err();\n\n assert!(\n err.to_string().contains(\"did you mean --backend age\"),\n \"error should hint at the age backend: {err:#}\"\n );\n }\n\n fn shadow_key(\n config: &mut Config,\n key: &str,\n path: std::path::PathBuf,\n is_managed_overlay: bool,\n ) {\n let kind = if is_managed_overlay {\n crate::config::EnvOverrideKind::Overlay\n } else {\n crate::config::EnvOverrideKind::Global\n };\n config.env_override_sources.insert(\n key.to_string(),\n crate::config::EnvOverrideSource {\n path,\n kind,\n is_managed_overlay,\n },\n );\n }\n\n #[test]\n fn resolve_env_write_target_returns_config_toml_when_unshadowed() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", false).unwrap();\n\n assert!(matches!(target, EnvWriteTarget::ConfigToml));\n }\n\n #[test]\n fn resolve_env_write_target_refuses_without_force_when_shadowed() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = resolve_env_write_target(&config, \"MY_TOKEN\", false).unwrap_err();\n\n assert!(\n err.to_string().contains(override_path.to_str().unwrap()),\n \"error should name the winning override file: {err:#}\"\n );\n assert!(\n err.to_string().contains(\"--force\"),\n \"error should hint at --force: {err:#}\"\n );\n }\n\n #[test]\n fn resolve_env_write_target_returns_override_file_with_force() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", true).unwrap();\n\n match target {\n EnvWriteTarget::OverrideFile(source) => assert_eq!(source.path, override_path),\n EnvWriteTarget::ConfigToml => panic!(\"expected the shadowing override file\"),\n }\n }\n\n #[test]\n fn resolve_env_write_target_allows_managed_overlay_with_force() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let overlay_path = dir.join(\"overlay\").join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", overlay_path.clone(), true);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", true).unwrap();\n\n match target {\n EnvWriteTarget::OverrideFile(source) => {\n assert_eq!(source.path, overlay_path);\n assert!(source.is_managed_overlay);\n }\n EnvWriteTarget::ConfigToml => panic!(\"expected the managed overlay override file\"),\n }\n }\n\n #[tokio::test]\n async fn env_set_refuses_when_shadowed_without_force() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = handle_set(&config, \"MY_TOKEN\", \"newval\", false)\n .await\n .unwrap_err();\n\n assert!(err.to_string().contains(override_path.to_str().unwrap()));\n assert!(\n !fs::try_exists(&override_path).await.unwrap(),\n \"refused write must not touch the override file\"\n );\n assert!(\n !fs::read_to_string(config.config_path())\n .await\n .unwrap_or_default()\n .contains(\"MY_TOKEN\"),\n \"refused write must not touch config.toml either\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_set_writes_into_override_file_when_forced() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"old\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n handle_set(&config, \"MY_TOKEN\", \"newval\", true)\n .await\n .unwrap();\n\n let content = fs::read_to_string(&override_path).await.unwrap();\n assert!(content.contains(\"MY_TOKEN = \\\"newval\\\"\"));\n assert!(\n !fs::read_to_string(config.config_path())\n .await\n .unwrap_or_default()\n .contains(\"MY_TOKEN\"),\n \"forced write must go into the override file, not config.toml\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_refuses_when_shadowed_without_force() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"secret\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = handle_delete(&config, \"MY_TOKEN\", false).await.unwrap_err();\n\n assert!(err.to_string().contains(override_path.to_str().unwrap()));\n let content = fs::read_to_string(&override_path).await.unwrap();\n assert!(\n content.contains(\"MY_TOKEN\"),\n \"refused delete must leave the override file untouched\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_removes_from_override_file_when_forced() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"secret\\\"\\nOTHER = \\\"kept\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n handle_delete(&config, \"MY_TOKEN\", true).await.unwrap();\n\n let content = fs::read_to_string(&override_path).await.unwrap();\n let table: toml::Table = toml::from_str(&content).unwrap();\n assert!(!table.contains_key(\"MY_TOKEN\"));\n assert!(table.contains_key(\"OTHER\"));\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n}\n"}, "a86107207dfc38b937d9b1a1bdb00f14251cbb93": {"t": 1785750186.1273572, "n": 41504, "h": "d23acfdb5609c515", "c": "//! Handlers for `shine env list/set/delete/get/decrypt/export/encrypt`.\n\nuse anyhow::{Context, Result, bail};\n\nuse super::{EnvConfig, StoredValue, resolve_stored_value, secret_key};\nuse crate::config::{Config, EnvOverrideKind, EnvOverrideSource};\nuse crate::secret::{BackendKind, EncryptRecipients};\nuse crate::{colors, path_display, secret, shells};\n\n/// Which layer supplied a variable's effective value, used to group the\n/// `env list` output. `Config` is the `config.toml [env]` table (global or\n/// project, deliberately not distinguished); the rest are `shine.env.toml`\n/// override files. Ordering matches display order (`config.toml` first, then\n/// override layers low-to-high by precedence).\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\nenum EnvSourceGroup {\n Config,\n Global,\n Overlay { managed: bool },\n Project,\n}\n\nimpl EnvSourceGroup {\n /// Fixed display order. Lower sorts first.\n fn order(self) -> u8 {\n match self {\n EnvSourceGroup::Config => 0,\n EnvSourceGroup::Global => 1,\n EnvSourceGroup::Overlay { .. } => 2,\n EnvSourceGroup::Project => 3,\n }\n }\n\n /// The bold header label for this section.\n fn label(self) -> &'static str {\n match self {\n EnvSourceGroup::Config => \"config.toml\",\n EnvSourceGroup::Global => \"global env file\",\n EnvSourceGroup::Overlay { managed: false } => \"overlay\",\n EnvSourceGroup::Overlay { managed: true } => \"overlay (managed)\",\n EnvSourceGroup::Project => \"project env file\",\n }\n }\n}\n\n/// Classify a key by its override source: no override → `config.toml [env]`,\n/// otherwise the override file's layer (folding `is_managed_overlay` into the\n/// `Overlay` variant).\nfn env_source_group(source: Option<&EnvOverrideSource>) -> EnvSourceGroup {\n match source {\n None => EnvSourceGroup::Config,\n Some(source) => match source.kind {\n EnvOverrideKind::Global => EnvSourceGroup::Global,\n EnvOverrideKind::Overlay => EnvSourceGroup::Overlay {\n managed: source.is_managed_overlay,\n },\n EnvOverrideKind::Project => EnvSourceGroup::Project,\n },\n }\n}\n\n/// Partition `keys` into ordered, non-empty sections by source group,\n/// preserving each key's relative order within its group. Pure over a\n/// `source_of` lookup so it can be unit-tested without terminal/config I/O.\nfn group_env_keys<'a>(\n keys: impl Iterator<Item = &'a str>,\n source_of: impl Fn(&str) -> Option<&'a EnvOverrideSource>,\n) -> Vec<(EnvSourceGroup, Vec<&'a str>)> {\n let mut groups: Vec<(EnvSourceGroup, Vec<&'a str>)> = Vec::new();\n for key in keys {\n let group = env_source_group(source_of(key));\n match groups.iter_mut().find(|(g, _)| *g == group) {\n Some((_, members)) => members.push(key),\n None => groups.push((group, vec![key])),\n }\n }\n groups.sort_by_key(|(group, _)| group.order());\n groups\n}\n\npub async fn handle_list(config: &Config, reveal: bool) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let catalog = super::catalog::load(config).await?;\n let terminal_width = usize::from(console::Term::stdout().size().1).max(40);\n let key_width = env\n .iter()\n .map(|(key, _)| key.chars().count())\n .max()\n .unwrap_or(0);\n\n println!(\"{}\", colors::bold(\"Environment\"));\n println!();\n if env.as_map().is_empty() {\n println!(\" {}\", colors::dim(\"No variables configured.\"));\n println!();\n }\n\n let groups = group_env_keys(env.iter().map(|(k, _)| k), |key| {\n config.env_override_source(key)\n });\n for (group, keys) in groups {\n // Header: bold label, plus the override file's path for non-config groups.\n match keys.first().and_then(|key| config.env_override_source(key)) {\n Some(source) => println!(\n \"{} {}\",\n colors::bold(group.label()),\n colors::dim(&path_display::format(&source.path))\n ),\n None => println!(\"{}\", colors::bold(group.label())),\n }\n for k in keys {\n let v = env.get(k).unwrap_or_default();\n let metadata = catalog.get(k);\n let description = env\n .description(k)\n .or_else(|| metadata.map(|item| item.description.as_str()))\n .unwrap_or_default();\n let sensitive = metadata.is_some_and(|item| item.sensitive) || is_sensitive_env_key(k);\n let display_value = display_env_value(v, sensitive, reveal);\n let (display_value, description) =\n fit_env_row(&display_value, description, key_width, terminal_width);\n let key_padding = \" \".repeat(key_width.saturating_sub(k.chars().count()));\n if description.is_empty() {\n println!(\" {}{} {}\", colors::cyan(k), key_padding, display_value);\n } else {\n println!(\n \" {}{} {:<value_width$} {}\",\n colors::cyan(k),\n key_padding,\n display_value,\n colors::dim(&description),\n value_width = env_value_width(key_width, terminal_width),\n );\n }\n }\n println!();\n }\n\n println!(\n \" {} {}\",\n colors::dim(\"Config\"),\n colors::dim(&path_display::format(config.config_path()))\n );\n println!(\n \" {}\",\n colors::dim(&format!(\"{} variables\", env.as_map().len()))\n );\n Ok(())\n}\n\nfn is_sensitive_env_key(key: &str) -> bool {\n let key = key.to_ascii_uppercase();\n [\n \"SECRET\",\n \"TOKEN\",\n \"PASSWORD\",\n \"PASSPHRASE\",\n \"API_KEY\",\n \"PRIVATE_KEY\",\n \"ACCESS_KEY\",\n \"SUBSCRIPTION_URL\",\n ]\n .iter()\n .any(|suffix| key == *suffix || key.ends_with(&format!(\"_{suffix}\")))\n}\n\nfn display_env_value(value: &str, sensitive: bool, reveal: bool) -> String {\n if value.is_empty() {\n \"<empty>\".to_string()\n } else if sensitive && !reveal {\n \"<redacted>\".to_string()\n } else {\n value.to_string()\n }\n}\n\nfn env_value_width(key_width: usize, terminal_width: usize) -> usize {\n terminal_width.saturating_sub(key_width + 28).clamp(12, 36)\n}\n\nfn fit_env_row(\n value: &str,\n description: &str,\n key_width: usize,\n terminal_width: usize,\n) -> (String, String) {\n let value_width = env_value_width(key_width, terminal_width);\n let value = truncate_text(value, value_width);\n let description_width = terminal_width.saturating_sub(2 + key_width + 2 + value_width + 2);\n let description = if description_width < 8 {\n String::new()\n } else {\n truncate_text(description, description_width)\n };\n (value, description)\n}\n\nfn truncate_text(value: &str, max_width: usize) -> String {\n if value.chars().count() <= max_width {\n return value.to_string();\n }\n if max_width <= 1 {\n return \"…\".to_string();\n }\n let mut result = value.chars().take(max_width - 1).collect::<String>();\n result.push('…');\n result\n}\n\n/// Where an `env set`/`encrypt`/`delete` write should land: `config.toml [env]`\n/// (the default, unshadowed case), or a specific override file that already\n/// supplies the key's effective value.\n#[derive(Debug)]\nenum EnvWriteTarget<'a> {\n ConfigToml,\n OverrideFile(&'a crate::config::EnvOverrideSource),\n}\n\n/// Decide where a write to `key` should go. Refuses (unless `force`) when an\n/// override file already shadows `config.toml [env]` for this key, since a\n/// plain write there would silently have no effect on the resolved value. With\n/// `force`, warns loudly when the winning file is the shine-managed overlay\n/// mirror, since that write will be discarded on the next `shine preset pull`.\nfn resolve_env_write_target<'a>(\n config: &'a Config,\n key: &str,\n force: bool,\n) -> Result<EnvWriteTarget<'a>> {\n let Some(source) = config.env_override_source(key) else {\n return Ok(EnvWriteTarget::ConfigToml);\n };\n if !force {\n bail!(\n \"{key} currently resolves from {} (an env override file), which takes precedence over {}; this write would have no effect.\\nRe-run with --force to write directly into that file instead.\",\n path_display::format(&source.path),\n path_display::format(config.config_path()),\n );\n }\n if source.is_managed_overlay {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\n \"Warning: {} is the shine-managed overlay mirror; this change will be discarded on the next `shine preset pull`/`shine update`. Edit it upstream on the maintaining device instead.\",\n path_display::format(&source.path)\n ))\n );\n }\n Ok(EnvWriteTarget::OverrideFile(source))\n}\n\npub async fn handle_set(config: &Config, key: &str, value: &str, force: bool) -> Result<()> {\n let catalog = super::catalog::load(config).await?;\n let sensitive =\n catalog.get(key).is_some_and(|item| item.sensitive) || is_sensitive_env_key(key);\n let display_value = display_env_value(value, sensitive, false);\n match resolve_env_write_target(config, key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(key, value);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{display_value}\\\" in {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, key, Some(value)).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{display_value}\\\" in {}\",\n path_display::format(&source.path)\n ))\n );\n }\n }\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_delete(config: &Config, key: &str, force: bool) -> Result<()> {\n if !config.env.contains_key(key) && config.env_override_source(key).is_none() {\n bail!(\"{key} is not set in the active config [env]\");\n }\n match resolve_env_write_target(config, key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.remove(key);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"deleted {key} from {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, key, None).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"deleted {key} from {}\",\n path_display::format(&source.path)\n ))\n );\n }\n }\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_get(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n match env.get(key) {\n Some(v) => println!(\"{v}\"),\n None => {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\"{key} is not set in the active config [env]\"))\n );\n std::process::exit(1);\n }\n }\n Ok(())\n}\n\npub async fn handle_decrypt(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n let plaintext = secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {key}\"))?;\n print!(\"{plaintext}\");\n Ok(())\n}\n\npub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {\n validate_env_export_key(key)?;\n if let Some(alias) = alias {\n validate_env_export_key(alias)?;\n }\n let env = EnvConfig::load_or_init(config).await?;\n let value = match resolve_env_export_value(&env, key)? {\n EnvExportValue::Secret {\n key: secret_key,\n value,\n } => secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {secret_key}\"))?,\n EnvExportValue::Plaintext(value) => value.to_string(),\n };\n let export_as = alias.unwrap_or(key);\n println!(\n \"{}\",\n format_env_export(&config.shell_type, export_as, &value)\n );\n Ok(())\n}\n\ntype EnvExportValue<'a> = StoredValue<'a>;\n\nfn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {\n resolve_stored_value(env, key)\n}\n\nfn env_export_secret_key(key: &str) -> String {\n secret_key(key)\n}\n\nfn validate_env_export_key(key: &str) -> Result<()> {\n let mut chars = key.chars();\n let Some(first) = chars.next() else {\n bail!(\"env export key must not be empty\");\n };\n if !(first == '_' || first.is_ascii_alphabetic()) {\n bail!(\"env export key must start with a letter or underscore: {key}\");\n }\n if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {\n bail!(\"env export key must contain only letters, digits, and underscores: {key}\");\n }\n Ok(())\n}\n\n/// `pub(crate)` so `theme::handle_sync` can reuse the same per-shell quoting\n/// instead of adding a fourth `single_quote` implementation to the codebase\n/// (see docs/terminal-theme-sync-prd.md §7/§10).\npub(crate) fn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {\n match shell {\n shells::ShellType::Fish => format!(\"set -gx {key} {}\", fish_quote(value)),\n shells::ShellType::PowerShell => {\n format!(\"$env:{key} = {}\", powershell_string_quote(value))\n }\n _ => format!(\"export {key}={}\", posix_shell_quote(value)),\n }\n}\n\nfn posix_shell_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"'\\\\''\"))\n}\n\nfn fish_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\\\', \"\\\\\\\\\").replace('\\'', \"\\\\'\"))\n}\n\nfn powershell_string_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"''\"))\n}\n\n#[derive(Debug, PartialEq, Eq)]\nenum EnvEncryptOutput {\n Print,\n Set(String),\n}\n\nfn resolve_env_encrypt_output(\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<EnvEncryptOutput> {\n if let Some(key) = set_key {\n return Ok(EnvEncryptOutput::Set(key.to_string()));\n }\n if let Some(key) = from_key {\n validate_env_export_key(key)?;\n return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));\n }\n Ok(EnvEncryptOutput::Print)\n}\n\nfn resolve_encrypt_backend(config: &Config, backend: Option<&str>) -> Result<BackendKind> {\n if let Some(backend) = backend.map(str::trim).filter(|value| !value.is_empty()) {\n return backend.parse();\n }\n if let Some(backend) = config\n .secret_backend\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n {\n return backend.parse();\n }\n Ok(BackendKind::default())\n}\n\nfn clean_recipients(recipients: &[String]) -> Vec<String> {\n recipients\n .iter()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .collect()\n}\n\nfn resolve_encrypt_recipients(\n backend: BackendKind,\n cli_recipients: &[String],\n config: &Config,\n) -> Result<EncryptRecipients> {\n let cli_recipients = clean_recipients(cli_recipients);\n if !cli_recipients.is_empty() {\n if backend == BackendKind::Gpg\n && let Some(hint) = cli_recipients\n .iter()\n .find(|value| value.starts_with(\"age1\"))\n {\n bail!(\"recipient \\\"{hint}\\\" looks like an age recipient; did you mean --backend age?\");\n }\n return Ok(match backend {\n BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),\n BackendKind::Age => EncryptRecipients::Age(cli_recipients),\n });\n }\n\n match backend {\n BackendKind::Gpg => {\n let recipient = config\n .gpg_key_id\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .context(\n \"GPG recipient is required; pass -r/--recipient, set gpg_key_id, or set secret_backend/age_recipients for age\",\n )?;\n Ok(EncryptRecipients::Gpg(vec![recipient.to_string()]))\n }\n BackendKind::Age => {\n let recipients = clean_recipients(&config.age_recipients);\n if recipients.is_empty() {\n bail!(\n \"age recipients are required; pass -r/--recipient or set age_recipients in config.toml\"\n );\n }\n Ok(EncryptRecipients::Age(recipients))\n }\n }\n}\n\npub async fn handle_encrypt(\n config: &Config,\n backend: Option<&str>,\n recipients: &[String],\n set_key: Option<&str>,\n from_key: Option<&str>,\n force: bool,\n) -> Result<()> {\n use std::io::Read as _;\n\n let backend = resolve_encrypt_backend(config, backend)?;\n let recipients = resolve_encrypt_recipients(backend, recipients, config)?;\n let plaintext = if let Some(key) = from_key {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n value.as_bytes().to_vec()\n } else {\n let mut input = Vec::new();\n std::io::stdin()\n .read_to_end(&mut input)\n .context(\"reading secret from stdin\")?;\n input\n };\n let encoded = secret::encrypt_secret(&plaintext, &recipients)\n .await\n .context(\"encrypting secret\")?;\n match resolve_env_encrypt_output(set_key, from_key)? {\n EnvEncryptOutput::Set(key) => match resolve_env_write_target(config, &key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(&key, &encoded);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{encoded}\\\" in {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, &key, Some(&encoded)).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{encoded}\\\" in {}\",\n path_display::format(&source.path)\n ))\n );\n }\n },\n EnvEncryptOutput::Print => println!(\"{encoded}\"),\n }\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::config::Config;\n use tokio::fs;\n\n async fn make_temp_dir() -> std::path::PathBuf {\n crate::test_support::make_temp_dir(\"shine-env-cmd-test\").await\n }\n\n fn config_in(dir: &std::path::Path) -> Config {\n crate::test_support::test_config(dir)\n }\n\n #[test]\n fn env_show_redacts_sensitive_values() {\n assert_eq!(display_env_value(\"secret\", true, false), \"<redacted>\");\n assert_eq!(display_env_value(\"secret\", true, true), \"secret\");\n assert_eq!(display_env_value(\"\", true, false), \"<empty>\");\n assert!(is_sensitive_env_key(\"MY_API_KEY\"));\n assert!(is_sensitive_env_key(\"token\"));\n assert!(is_sensitive_env_key(\"SURGE_SUBSCRIPTION_URL\"));\n assert!(!is_sensitive_env_key(\"MONKEY\"));\n }\n\n fn source(kind: EnvOverrideKind, managed: bool) -> EnvOverrideSource {\n EnvOverrideSource {\n path: std::path::PathBuf::from(\"/tmp/shine.env.toml\"),\n kind,\n is_managed_overlay: managed,\n }\n }\n\n #[test]\n fn env_source_group_maps_each_layer() {\n assert_eq!(env_source_group(None), EnvSourceGroup::Config);\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Global, false))),\n EnvSourceGroup::Global\n );\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Overlay, false))),\n EnvSourceGroup::Overlay { managed: false }\n );\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Overlay, true))),\n EnvSourceGroup::Overlay { managed: true }\n );\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Project, false))),\n EnvSourceGroup::Project\n );\n }\n\n #[test]\n fn group_env_keys_orders_sections_and_skips_empty() {\n let global = source(EnvOverrideKind::Global, false);\n let overlay = source(EnvOverrideKind::Overlay, true);\n // Keys deliberately out of source order; config keys have no override.\n let keys = [\"PROJECT_LESS\", \"FROM_OVERLAY\", \"FROM_CONFIG\", \"FROM_GLOBAL\"];\n let groups = group_env_keys(keys.iter().copied(), |key| match key {\n \"FROM_GLOBAL\" => Some(&global),\n \"FROM_OVERLAY\" => Some(&overlay),\n _ => None,\n });\n\n // Only Config, Global, Overlay are present (Project skipped), in order.\n assert_eq!(\n groups.iter().map(|(g, _)| *g).collect::<Vec<_>>(),\n vec![\n EnvSourceGroup::Config,\n EnvSourceGroup::Global,\n EnvSourceGroup::Overlay { managed: true },\n ]\n );\n assert_eq!(groups[0].1, vec![\"PROJECT_LESS\", \"FROM_CONFIG\"]);\n assert_eq!(groups[1].1, vec![\"FROM_GLOBAL\"]);\n assert_eq!(groups[2].1, vec![\"FROM_OVERLAY\"]);\n }\n\n #[test]\n fn group_env_keys_all_config_yields_single_group() {\n let keys = [\"A\", \"B\", \"C\"];\n let groups = group_env_keys(keys.iter().copied(), |_| None);\n assert_eq!(groups.len(), 1);\n assert_eq!(groups[0].0, EnvSourceGroup::Config);\n assert_eq!(groups[0].1, vec![\"A\", \"B\", \"C\"]);\n }\n\n #[test]\n fn env_source_group_labels_are_stable() {\n assert_eq!(EnvSourceGroup::Config.label(), \"config.toml\");\n assert_eq!(EnvSourceGroup::Global.label(), \"global env file\");\n assert_eq!(\n EnvSourceGroup::Overlay { managed: false }.label(),\n \"overlay\"\n );\n assert_eq!(\n EnvSourceGroup::Overlay { managed: true }.label(),\n \"overlay (managed)\"\n );\n assert_eq!(EnvSourceGroup::Project.label(), \"project env file\");\n }\n\n #[test]\n fn env_show_truncates_long_values_to_requested_width() {\n assert_eq!(truncate_text(\"abcdefgh\", 5), \"abcd…\");\n let (value, description) = fit_env_row(\n \"abcdefghijklmnopqrstuvwxyz\",\n \"A description that is also fairly long\",\n 8,\n 48,\n );\n assert!(value.chars().count() <= env_value_width(8, 48));\n assert!(description.chars().count() <= 48);\n }\n\n #[test]\n fn env_export_uses_alias_as_variable_name() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"MY_ALIAS\", value),\n \"export MY_ALIAS='secret123'\"\n );\n }\n\n #[test]\n fn env_export_alias_formats_powershell_correctly() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"MY_ALIAS\", value),\n \"$env:MY_ALIAS = 'secret123'\"\n );\n }\n\n #[tokio::test]\n async fn env_delete_removes_key_from_saved_config() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n config.env.insert(\"MY_TOKEN\".into(), \"secret\".into());\n config.save().await.unwrap();\n\n handle_delete(&config, \"MY_TOKEN\", false).await.unwrap();\n\n let contents = fs::read_to_string(config.config_path()).await.unwrap();\n let parsed: toml::Table = toml::from_str(&contents).unwrap();\n let env = parsed\n .get(\"env\")\n .and_then(|value| value.as_table())\n .unwrap();\n assert!(\n !env.contains_key(\"MY_TOKEN\"),\n \"deleted key should not remain in saved config: {contents}\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_fails_when_key_is_missing() {\n let dir = make_temp_dir().await;\n let config = config_in(&dir);\n\n let err = handle_delete(&config, \"MY_TOKEN\", false).await.unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN is not set in the active config [env]\"),\n \"error should explain missing key: {err:#}\"\n );\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[test]\n fn env_export_secret_key_appends_secret_suffix() {\n assert_eq!(\n env_export_secret_key(\"DEEPSEEK_API_KEY\"),\n \"DEEPSEEK_API_KEY_SECRET\"\n );\n assert_eq!(env_export_secret_key(\"xxx\"), \"xxx_SECRET\");\n }\n\n #[test]\n fn env_export_resolves_secret_when_present() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_falls_back_to_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Plaintext(\"plain\")\n );\n }\n\n #[test]\n fn env_export_secret_wins_over_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_reports_both_missing_keys() {\n let env = EnvConfig::default();\n\n let err = resolve_env_export_value(&env, \"MY_TOKEN\").unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]\"),\n \"error should explain both checked keys: {err:#}\"\n );\n }\n\n #[test]\n fn env_export_key_validation_accepts_shell_variable_names() {\n for key in [\"FOO\", \"_FOO\", \"foo_123\", \"A1\"] {\n validate_env_export_key(key).unwrap();\n }\n }\n\n #[test]\n fn env_export_key_validation_rejects_unsafe_names() {\n for key in [\"\", \"1FOO\", \"FOO-BAR\", \"FOO;BAR\", \"FOO BAR\", \"FOO.SECRET\"] {\n assert!(\n validate_env_export_key(key).is_err(),\n \"key should be rejected: {key}\"\n );\n }\n }\n\n #[test]\n fn env_export_formats_posix_shell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"TOKEN\", value),\n \"export TOKEN='abc def'\\\\''ghi$HOME\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_fish_shell_code_safely() {\n let value = \"abc def'ghi\\\\path\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Fish, \"TOKEN\", value),\n \"set -gx TOKEN 'abc def\\\\'ghi\\\\\\\\path\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_powershell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; Remove-Item /\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"TOKEN\", value),\n \"$env:TOKEN = 'abc def''ghi$HOME\\nnext; Remove-Item /'\"\n );\n }\n\n #[test]\n fn env_encrypt_output_defaults_from_key_to_secret_key() {\n assert_eq!(\n resolve_env_encrypt_output(None, Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"GH_TOKEN_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_explicit_set_wins_over_default() {\n assert_eq!(\n resolve_env_encrypt_output(Some(\"CUSTOM_SECRET\"), Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"CUSTOM_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_prints_stdin_without_set() {\n assert_eq!(\n resolve_env_encrypt_output(None, None).unwrap(),\n EnvEncryptOutput::Print\n );\n }\n\n #[test]\n fn env_encrypt_output_rejects_invalid_inferred_from_key() {\n let err = resolve_env_encrypt_output(None, Some(\"GH-TOKEN\")).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"env export key must contain only letters, digits, and underscores\"),\n \"error should explain invalid inferred key: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_backend_cli_wins_over_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, Some(\"gpg\")).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_backend_falls_back_to_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Age\n );\n }\n\n #[test]\n fn encrypt_backend_defaults_to_gpg() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_recipients_cli_wins_over_config_for_gpg() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"cli@example.com\".to_string()], &config)\n .unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"cli@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"config@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_treats_empty_config_as_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\" \".to_string());\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_gpg_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_age_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.age_recipients = vec![\"age1qexample\".to_string()];\n\n let recipients = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Age(values) => assert_eq!(values, vec![\"age1qexample\"]),\n EncryptRecipients::Gpg(_) => panic!(\"expected age recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_age_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap_err();\n\n assert!(\n err.to_string().contains(\"age recipients are required\"),\n \"error should explain how to set age recipients: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_hints_when_age_recipient_used_with_gpg_backend() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"age1qexample\".to_string()], &config)\n .unwrap_err();\n\n assert!(\n err.to_string().contains(\"did you mean --backend age\"),\n \"error should hint at the age backend: {err:#}\"\n );\n }\n\n fn shadow_key(\n config: &mut Config,\n key: &str,\n path: std::path::PathBuf,\n is_managed_overlay: bool,\n ) {\n let kind = if is_managed_overlay {\n crate::config::EnvOverrideKind::Overlay\n } else {\n crate::config::EnvOverrideKind::Global\n };\n config.env_override_sources.insert(\n key.to_string(),\n crate::config::EnvOverrideSource {\n path,\n kind,\n is_managed_overlay,\n },\n );\n }\n\n #[test]\n fn resolve_env_write_target_returns_config_toml_when_unshadowed() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", false).unwrap();\n\n assert!(matches!(target, EnvWriteTarget::ConfigToml));\n }\n\n #[test]\n fn resolve_env_write_target_refuses_without_force_when_shadowed() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = resolve_env_write_target(&config, \"MY_TOKEN\", false).unwrap_err();\n\n assert!(\n err.to_string().contains(override_path.to_str().unwrap()),\n \"error should name the winning override file: {err:#}\"\n );\n assert!(\n err.to_string().contains(\"--force\"),\n \"error should hint at --force: {err:#}\"\n );\n }\n\n #[test]\n fn resolve_env_write_target_returns_override_file_with_force() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", true).unwrap();\n\n match target {\n EnvWriteTarget::OverrideFile(source) => assert_eq!(source.path, override_path),\n EnvWriteTarget::ConfigToml => panic!(\"expected the shadowing override file\"),\n }\n }\n\n #[test]\n fn resolve_env_write_target_allows_managed_overlay_with_force() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let overlay_path = dir.join(\"overlay\").join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", overlay_path.clone(), true);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", true).unwrap();\n\n match target {\n EnvWriteTarget::OverrideFile(source) => {\n assert_eq!(source.path, overlay_path);\n assert!(source.is_managed_overlay);\n }\n EnvWriteTarget::ConfigToml => panic!(\"expected the managed overlay override file\"),\n }\n }\n\n #[tokio::test]\n async fn env_set_refuses_when_shadowed_without_force() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = handle_set(&config, \"MY_TOKEN\", \"newval\", false)\n .await\n .unwrap_err();\n\n assert!(err.to_string().contains(override_path.to_str().unwrap()));\n assert!(\n !fs::try_exists(&override_path).await.unwrap(),\n \"refused write must not touch the override file\"\n );\n assert!(\n !fs::read_to_string(config.config_path())\n .await\n .unwrap_or_default()\n .contains(\"MY_TOKEN\"),\n \"refused write must not touch config.toml either\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_set_writes_into_override_file_when_forced() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"old\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n handle_set(&config, \"MY_TOKEN\", \"newval\", true)\n .await\n .unwrap();\n\n let content = fs::read_to_string(&override_path).await.unwrap();\n assert!(content.contains(\"MY_TOKEN = \\\"newval\\\"\"));\n assert!(\n !fs::read_to_string(config.config_path())\n .await\n .unwrap_or_default()\n .contains(\"MY_TOKEN\"),\n \"forced write must go into the override file, not config.toml\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_refuses_when_shadowed_without_force() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"secret\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = handle_delete(&config, \"MY_TOKEN\", false).await.unwrap_err();\n\n assert!(err.to_string().contains(override_path.to_str().unwrap()));\n let content = fs::read_to_string(&override_path).await.unwrap();\n assert!(\n content.contains(\"MY_TOKEN\"),\n \"refused delete must leave the override file untouched\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_removes_from_override_file_when_forced() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"secret\\\"\\nOTHER = \\\"kept\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n handle_delete(&config, \"MY_TOKEN\", true).await.unwrap();\n\n let content = fs::read_to_string(&override_path).await.unwrap();\n let table: toml::Table = toml::from_str(&content).unwrap();\n assert!(!table.contains_key(\"MY_TOKEN\"));\n assert!(table.contains_key(\"OTHER\"));\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n}\n"}, "bc5f71a1242e9548b4d53492e363295e59f01b3a": {"t": 1785750187.4211013, "n": 41490, "h": "c1c292583f446eb1", "c": "//! Handlers for `shine env show/set/delete/get/decrypt/export/encrypt`.\n\nuse anyhow::{Context, Result, bail};\n\nuse super::{EnvConfig, StoredValue, resolve_stored_value, secret_key};\nuse crate::config::{Config, EnvOverrideKind, EnvOverrideSource};\nuse crate::secret::{BackendKind, EncryptRecipients};\nuse crate::{colors, path_display, secret, shells};\n\n/// Which layer supplied a variable's effective value, used to group the\n/// `env show` output. `Config` is the `config.toml [env]` table (global or\n/// project, deliberately not distinguished); the rest are `shine.env.toml`\n/// override files. Ordering matches display order (`config.toml` first, then\n/// override layers low-to-high by precedence).\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\nenum EnvSourceGroup {\n Config,\n Global,\n Overlay { managed: bool },\n Project,\n}\n\nimpl EnvSourceGroup {\n /// Fixed display order. Lower sorts first.\n fn order(self) -> u8 {\n match self {\n EnvSourceGroup::Config => 0,\n EnvSourceGroup::Global => 1,\n EnvSourceGroup::Overlay { .. } => 2,\n EnvSourceGroup::Project => 3,\n }\n }\n\n /// The bold header label for this section.\n fn label(self) -> &'static str {\n match self {\n EnvSourceGroup::Config => \"config.toml\",\n EnvSourceGroup::Global => \"global env file\",\n EnvSourceGroup::Overlay { managed: false } => \"overlay\",\n EnvSourceGroup::Overlay { managed: true } => \"overlay (managed)\",\n EnvSourceGroup::Project => \"project env file\",\n }\n }\n}\n\n/// Classify a key by its override source: no override → `config.toml [env]`,\n/// otherwise the override file's layer (folding `is_managed_overlay` into the\n/// `Overlay` variant).\nfn env_source_group(source: Option<&EnvOverrideSource>) -> EnvSourceGroup {\n match source {\n None => EnvSourceGroup::Config,\n Some(source) => match source.kind {\n EnvOverrideKind::Global => EnvSourceGroup::Global,\n EnvOverrideKind::Overlay => EnvSourceGroup::Overlay {\n managed: source.is_managed_overlay,\n },\n EnvOverrideKind::Project => EnvSourceGroup::Project,\n },\n }\n}\n\n/// Partition `keys` into ordered, non-empty sections by source group,\n/// preserving each key's relative order within its group. Pure over a\n/// `source_of` lookup so it can be unit-tested without terminal/config I/O.\nfn group_env_keys<'a>(\n keys: impl Iterator<Item = &'a str>,\n source_of: impl Fn(&str) -> Option<&'a EnvOverrideSource>,\n) -> Vec<(EnvSourceGroup, Vec<&'a str>)> {\n let mut groups: Vec<(EnvSourceGroup, Vec<&'a str>)> = Vec::new();\n for key in keys {\n let group = env_source_group(source_of(key));\n match groups.iter_mut().find(|(g, _)| *g == group) {\n Some((_, members)) => members.push(key),\n None => groups.push((group, vec![key])),\n }\n }\n groups.sort_by_key(|(group, _)| group.order());\n groups\n}\n\npub async fn handle_show(config: &Config, reveal: bool) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let catalog = super::catalog::load(config).await?;\n let terminal_width = usize::from(console::Term::stdout().size().1).max(40);\n let key_width = env\n .iter()\n .map(|(key, _)| key.chars().count())\n .max()\n .unwrap_or(0);\n\n println!(\"{}\", colors::bold(\"Environment\"));\n println!();\n if env.as_map().is_empty() {\n println!(\" {}\", colors::dim(\"No variables configured.\"));\n println!();\n }\n\n let groups = group_env_keys(env.iter().map(|(k, _)| k), |key| {\n config.env_override_source(key)\n });\n for (group, keys) in groups {\n // Header: bold label, plus the override file's path for non-config groups.\n match keys.first().and_then(|key| config.env_override_source(key)) {\n Some(source) => println!(\n \"{} {}\",\n colors::bold(group.label()),\n colors::dim(&path_display::format(&source.path))\n ),\n None => println!(\"{}\", colors::bold(group.label())),\n }\n for k in keys {\n let v = env.get(k).unwrap_or_default();\n let metadata = catalog.get(k);\n let description = env\n .description(k)\n .or_else(|| metadata.map(|item| item.description.as_str()))\n .unwrap_or_default();\n let sensitive = metadata.is_some_and(|item| item.sensitive) || is_sensitive_env_key(k);\n let display_value = display_env_value(v, sensitive, reveal);\n let (display_value, description) =\n fit_env_row(&display_value, description, key_width, terminal_width);\n let key_padding = \" \".repeat(key_width.saturating_sub(k.chars().count()));\n if description.is_empty() {\n println!(\" {}{} {}\", colors::cyan(k), key_padding, display_value);\n } else {\n println!(\n \" {}{} {:<value_width$} {}\",\n colors::cyan(k),\n key_padding,\n display_value,\n colors::dim(&description),\n value_width = env_value_width(key_width, terminal_width),\n );\n }\n }\n println!();\n }\n\n println!(\n \" {} {}\",\n colors::dim(\"Config\"),\n colors::dim(&path_display::format(config.config_path()))\n );\n println!(\n \" {}\",\n colors::dim(&format!(\"{} variables\", env.as_map().len()))\n );\n Ok(())\n}\n\nfn is_sensitive_env_key(key: &str) -> bool {\n let key = key.to_ascii_uppercase();\n [\n \"SECRET\",\n \"TOKEN\",\n \"PASSWORD\",\n \"PASSPHRASE\",\n \"API_KEY\",\n \"PRIVATE_KEY\",\n \"ACCESS_KEY\",\n \"SUBSCRIPTION_URL\",\n ]\n .iter()\n .any(|suffix| key == *suffix || key.ends_with(&format!(\"_{suffix}\")))\n}\n\nfn display_env_value(value: &str, sensitive: bool, reveal: bool) -> String {\n if value.is_empty() {\n \"<empty>\".to_string()\n } else if sensitive && !reveal {\n \"<redacted>\".to_string()\n } else {\n value.to_string()\n }\n}\n\nfn env_value_width(key_width: usize, terminal_width: usize) -> usize {\n terminal_width.saturating_sub(key_width + 28).clamp(12, 36)\n}\n\nfn fit_env_row(\n value: &str,\n description: &str,\n key_width: usize,\n terminal_width: usize,\n) -> (String, String) {\n let value_width = env_value_width(key_width, terminal_width);\n let value = truncate_text(value, value_width);\n let description_width = terminal_width.saturating_sub(2 + key_width + 2 + value_width + 2);\n let description = if description_width < 8 {\n String::new()\n } else {\n truncate_text(description, description_width)\n };\n (value, description)\n}\n\nfn truncate_text(value: &str, max_width: usize) -> String {\n if value.chars().count() <= max_width {\n return value.to_string();\n }\n if max_width <= 1 {\n return \"…\".to_string();\n }\n let mut result = value.chars().take(max_width - 1).collect::<String>();\n result.push('…');\n result\n}\n\n/// Where an `env set`/`encrypt`/`delete` write should land: `config.toml [env]`\n/// (the default, unshadowed case), or a specific override file that already\n/// supplies the key's effective value.\n#[derive(Debug)]\nenum EnvWriteTarget<'a> {\n ConfigToml,\n OverrideFile(&'a crate::config::EnvOverrideSource),\n}\n\n/// Decide where a write to `key` should go. Refuses (unless `force`) when an\n/// override file already shadows `config.toml [env]` for this key, since a\n/// plain write there would silently have no effect on the resolved value. With\n/// `force`, warns loudly when the winning file is the shine-managed overlay\n/// mirror, since that write will be discarded on the next `shine pull`.\nfn resolve_env_write_target<'a>(\n config: &'a Config,\n key: &str,\n force: bool,\n) -> Result<EnvWriteTarget<'a>> {\n let Some(source) = config.env_override_source(key) else {\n return Ok(EnvWriteTarget::ConfigToml);\n };\n if !force {\n bail!(\n \"{key} currently resolves from {} (an env override file), which takes precedence over {}; this write would have no effect.\\nRe-run with --force to write directly into that file instead.\",\n path_display::format(&source.path),\n path_display::format(config.config_path()),\n );\n }\n if source.is_managed_overlay {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\n \"Warning: {} is the shine-managed overlay mirror; this change will be discarded on the next `shine pull`/`shine update`. Edit it upstream on the maintaining device instead.\",\n path_display::format(&source.path)\n ))\n );\n }\n Ok(EnvWriteTarget::OverrideFile(source))\n}\n\npub async fn handle_set(config: &Config, key: &str, value: &str, force: bool) -> Result<()> {\n let catalog = super::catalog::load(config).await?;\n let sensitive =\n catalog.get(key).is_some_and(|item| item.sensitive) || is_sensitive_env_key(key);\n let display_value = display_env_value(value, sensitive, false);\n match resolve_env_write_target(config, key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(key, value);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{display_value}\\\" in {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, key, Some(value)).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{display_value}\\\" in {}\",\n path_display::format(&source.path)\n ))\n );\n }\n }\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_delete(config: &Config, key: &str, force: bool) -> Result<()> {\n if !config.env.contains_key(key) && config.env_override_source(key).is_none() {\n bail!(\"{key} is not set in the active config [env]\");\n }\n match resolve_env_write_target(config, key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.remove(key);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"deleted {key} from {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, key, None).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"deleted {key} from {}\",\n path_display::format(&source.path)\n ))\n );\n }\n }\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_get(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n match env.get(key) {\n Some(v) => println!(\"{v}\"),\n None => {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\"{key} is not set in the active config [env]\"))\n );\n std::process::exit(1);\n }\n }\n Ok(())\n}\n\npub async fn handle_decrypt(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n let plaintext = secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {key}\"))?;\n print!(\"{plaintext}\");\n Ok(())\n}\n\npub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {\n validate_env_export_key(key)?;\n if let Some(alias) = alias {\n validate_env_export_key(alias)?;\n }\n let env = EnvConfig::load_or_init(config).await?;\n let value = match resolve_env_export_value(&env, key)? {\n EnvExportValue::Secret {\n key: secret_key,\n value,\n } => secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {secret_key}\"))?,\n EnvExportValue::Plaintext(value) => value.to_string(),\n };\n let export_as = alias.unwrap_or(key);\n println!(\n \"{}\",\n format_env_export(&config.shell_type, export_as, &value)\n );\n Ok(())\n}\n\ntype EnvExportValue<'a> = StoredValue<'a>;\n\nfn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {\n resolve_stored_value(env, key)\n}\n\nfn env_export_secret_key(key: &str) -> String {\n secret_key(key)\n}\n\nfn validate_env_export_key(key: &str) -> Result<()> {\n let mut chars = key.chars();\n let Some(first) = chars.next() else {\n bail!(\"env export key must not be empty\");\n };\n if !(first == '_' || first.is_ascii_alphabetic()) {\n bail!(\"env export key must start with a letter or underscore: {key}\");\n }\n if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {\n bail!(\"env export key must contain only letters, digits, and underscores: {key}\");\n }\n Ok(())\n}\n\n/// `pub(crate)` so `theme::handle_sync` can reuse the same per-shell quoting\n/// instead of adding a fourth `single_quote` implementation to the codebase\n/// (see docs/terminal-theme-sync-prd.md §7/§10).\npub(crate) fn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {\n match shell {\n shells::ShellType::Fish => format!(\"set -gx {key} {}\", fish_quote(value)),\n shells::ShellType::PowerShell => {\n format!(\"$env:{key} = {}\", powershell_string_quote(value))\n }\n _ => format!(\"export {key}={}\", posix_shell_quote(value)),\n }\n}\n\nfn posix_shell_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"'\\\\''\"))\n}\n\nfn fish_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\\\', \"\\\\\\\\\").replace('\\'', \"\\\\'\"))\n}\n\nfn powershell_string_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"''\"))\n}\n\n#[derive(Debug, PartialEq, Eq)]\nenum EnvEncryptOutput {\n Print,\n Set(String),\n}\n\nfn resolve_env_encrypt_output(\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<EnvEncryptOutput> {\n if let Some(key) = set_key {\n return Ok(EnvEncryptOutput::Set(key.to_string()));\n }\n if let Some(key) = from_key {\n validate_env_export_key(key)?;\n return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));\n }\n Ok(EnvEncryptOutput::Print)\n}\n\nfn resolve_encrypt_backend(config: &Config, backend: Option<&str>) -> Result<BackendKind> {\n if let Some(backend) = backend.map(str::trim).filter(|value| !value.is_empty()) {\n return backend.parse();\n }\n if let Some(backend) = config\n .secret_backend\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n {\n return backend.parse();\n }\n Ok(BackendKind::default())\n}\n\nfn clean_recipients(recipients: &[String]) -> Vec<String> {\n recipients\n .iter()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .collect()\n}\n\nfn resolve_encrypt_recipients(\n backend: BackendKind,\n cli_recipients: &[String],\n config: &Config,\n) -> Result<EncryptRecipients> {\n let cli_recipients = clean_recipients(cli_recipients);\n if !cli_recipients.is_empty() {\n if backend == BackendKind::Gpg\n && let Some(hint) = cli_recipients\n .iter()\n .find(|value| value.starts_with(\"age1\"))\n {\n bail!(\"recipient \\\"{hint}\\\" looks like an age recipient; did you mean --backend age?\");\n }\n return Ok(match backend {\n BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),\n BackendKind::Age => EncryptRecipients::Age(cli_recipients),\n });\n }\n\n match backend {\n BackendKind::Gpg => {\n let recipient = config\n .gpg_key_id\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .context(\n \"GPG recipient is required; pass -r/--recipient, set gpg_key_id, or set secret_backend/age_recipients for age\",\n )?;\n Ok(EncryptRecipients::Gpg(vec![recipient.to_string()]))\n }\n BackendKind::Age => {\n let recipients = clean_recipients(&config.age_recipients);\n if recipients.is_empty() {\n bail!(\n \"age recipients are required; pass -r/--recipient or set age_recipients in config.toml\"\n );\n }\n Ok(EncryptRecipients::Age(recipients))\n }\n }\n}\n\npub async fn handle_encrypt(\n config: &Config,\n backend: Option<&str>,\n recipients: &[String],\n set_key: Option<&str>,\n from_key: Option<&str>,\n force: bool,\n) -> Result<()> {\n use std::io::Read as _;\n\n let backend = resolve_encrypt_backend(config, backend)?;\n let recipients = resolve_encrypt_recipients(backend, recipients, config)?;\n let plaintext = if let Some(key) = from_key {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n value.as_bytes().to_vec()\n } else {\n let mut input = Vec::new();\n std::io::stdin()\n .read_to_end(&mut input)\n .context(\"reading secret from stdin\")?;\n input\n };\n let encoded = secret::encrypt_secret(&plaintext, &recipients)\n .await\n .context(\"encrypting secret\")?;\n match resolve_env_encrypt_output(set_key, from_key)? {\n EnvEncryptOutput::Set(key) => match resolve_env_write_target(config, &key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(&key, &encoded);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{encoded}\\\" in {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, &key, Some(&encoded)).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{encoded}\\\" in {}\",\n path_display::format(&source.path)\n ))\n );\n }\n },\n EnvEncryptOutput::Print => println!(\"{encoded}\"),\n }\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::config::Config;\n use tokio::fs;\n\n async fn make_temp_dir() -> std::path::PathBuf {\n crate::test_support::make_temp_dir(\"shine-env-cmd-test\").await\n }\n\n fn config_in(dir: &std::path::Path) -> Config {\n crate::test_support::test_config(dir)\n }\n\n #[test]\n fn env_show_redacts_sensitive_values() {\n assert_eq!(display_env_value(\"secret\", true, false), \"<redacted>\");\n assert_eq!(display_env_value(\"secret\", true, true), \"secret\");\n assert_eq!(display_env_value(\"\", true, false), \"<empty>\");\n assert!(is_sensitive_env_key(\"MY_API_KEY\"));\n assert!(is_sensitive_env_key(\"token\"));\n assert!(is_sensitive_env_key(\"SURGE_SUBSCRIPTION_URL\"));\n assert!(!is_sensitive_env_key(\"MONKEY\"));\n }\n\n fn source(kind: EnvOverrideKind, managed: bool) -> EnvOverrideSource {\n EnvOverrideSource {\n path: std::path::PathBuf::from(\"/tmp/shine.env.toml\"),\n kind,\n is_managed_overlay: managed,\n }\n }\n\n #[test]\n fn env_source_group_maps_each_layer() {\n assert_eq!(env_source_group(None), EnvSourceGroup::Config);\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Global, false))),\n EnvSourceGroup::Global\n );\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Overlay, false))),\n EnvSourceGroup::Overlay { managed: false }\n );\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Overlay, true))),\n EnvSourceGroup::Overlay { managed: true }\n );\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Project, false))),\n EnvSourceGroup::Project\n );\n }\n\n #[test]\n fn group_env_keys_orders_sections_and_skips_empty() {\n let global = source(EnvOverrideKind::Global, false);\n let overlay = source(EnvOverrideKind::Overlay, true);\n // Keys deliberately out of source order; config keys have no override.\n let keys = [\"PROJECT_LESS\", \"FROM_OVERLAY\", \"FROM_CONFIG\", \"FROM_GLOBAL\"];\n let groups = group_env_keys(keys.iter().copied(), |key| match key {\n \"FROM_GLOBAL\" => Some(&global),\n \"FROM_OVERLAY\" => Some(&overlay),\n _ => None,\n });\n\n // Only Config, Global, Overlay are present (Project skipped), in order.\n assert_eq!(\n groups.iter().map(|(g, _)| *g).collect::<Vec<_>>(),\n vec![\n EnvSourceGroup::Config,\n EnvSourceGroup::Global,\n EnvSourceGroup::Overlay { managed: true },\n ]\n );\n assert_eq!(groups[0].1, vec![\"PROJECT_LESS\", \"FROM_CONFIG\"]);\n assert_eq!(groups[1].1, vec![\"FROM_GLOBAL\"]);\n assert_eq!(groups[2].1, vec![\"FROM_OVERLAY\"]);\n }\n\n #[test]\n fn group_env_keys_all_config_yields_single_group() {\n let keys = [\"A\", \"B\", \"C\"];\n let groups = group_env_keys(keys.iter().copied(), |_| None);\n assert_eq!(groups.len(), 1);\n assert_eq!(groups[0].0, EnvSourceGroup::Config);\n assert_eq!(groups[0].1, vec![\"A\", \"B\", \"C\"]);\n }\n\n #[test]\n fn env_source_group_labels_are_stable() {\n assert_eq!(EnvSourceGroup::Config.label(), \"config.toml\");\n assert_eq!(EnvSourceGroup::Global.label(), \"global env file\");\n assert_eq!(\n EnvSourceGroup::Overlay { managed: false }.label(),\n \"overlay\"\n );\n assert_eq!(\n EnvSourceGroup::Overlay { managed: true }.label(),\n \"overlay (managed)\"\n );\n assert_eq!(EnvSourceGroup::Project.label(), \"project env file\");\n }\n\n #[test]\n fn env_show_truncates_long_values_to_requested_width() {\n assert_eq!(truncate_text(\"abcdefgh\", 5), \"abcd…\");\n let (value, description) = fit_env_row(\n \"abcdefghijklmnopqrstuvwxyz\",\n \"A description that is also fairly long\",\n 8,\n 48,\n );\n assert!(value.chars().count() <= env_value_width(8, 48));\n assert!(description.chars().count() <= 48);\n }\n\n #[test]\n fn env_export_uses_alias_as_variable_name() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"MY_ALIAS\", value),\n \"export MY_ALIAS='secret123'\"\n );\n }\n\n #[test]\n fn env_export_alias_formats_powershell_correctly() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"MY_ALIAS\", value),\n \"$env:MY_ALIAS = 'secret123'\"\n );\n }\n\n #[tokio::test]\n async fn env_delete_removes_key_from_saved_config() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n config.env.insert(\"MY_TOKEN\".into(), \"secret\".into());\n config.save().await.unwrap();\n\n handle_delete(&config, \"MY_TOKEN\", false).await.unwrap();\n\n let contents = fs::read_to_string(config.config_path()).await.unwrap();\n let parsed: toml::Table = toml::from_str(&contents).unwrap();\n let env = parsed\n .get(\"env\")\n .and_then(|value| value.as_table())\n .unwrap();\n assert!(\n !env.contains_key(\"MY_TOKEN\"),\n \"deleted key should not remain in saved config: {contents}\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_fails_when_key_is_missing() {\n let dir = make_temp_dir().await;\n let config = config_in(&dir);\n\n let err = handle_delete(&config, \"MY_TOKEN\", false).await.unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN is not set in the active config [env]\"),\n \"error should explain missing key: {err:#}\"\n );\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[test]\n fn env_export_secret_key_appends_secret_suffix() {\n assert_eq!(\n env_export_secret_key(\"DEEPSEEK_API_KEY\"),\n \"DEEPSEEK_API_KEY_SECRET\"\n );\n assert_eq!(env_export_secret_key(\"xxx\"), \"xxx_SECRET\");\n }\n\n #[test]\n fn env_export_resolves_secret_when_present() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_falls_back_to_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Plaintext(\"plain\")\n );\n }\n\n #[test]\n fn env_export_secret_wins_over_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_reports_both_missing_keys() {\n let env = EnvConfig::default();\n\n let err = resolve_env_export_value(&env, \"MY_TOKEN\").unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]\"),\n \"error should explain both checked keys: {err:#}\"\n );\n }\n\n #[test]\n fn env_export_key_validation_accepts_shell_variable_names() {\n for key in [\"FOO\", \"_FOO\", \"foo_123\", \"A1\"] {\n validate_env_export_key(key).unwrap();\n }\n }\n\n #[test]\n fn env_export_key_validation_rejects_unsafe_names() {\n for key in [\"\", \"1FOO\", \"FOO-BAR\", \"FOO;BAR\", \"FOO BAR\", \"FOO.SECRET\"] {\n assert!(\n validate_env_export_key(key).is_err(),\n \"key should be rejected: {key}\"\n );\n }\n }\n\n #[test]\n fn env_export_formats_posix_shell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"TOKEN\", value),\n \"export TOKEN='abc def'\\\\''ghi$HOME\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_fish_shell_code_safely() {\n let value = \"abc def'ghi\\\\path\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Fish, \"TOKEN\", value),\n \"set -gx TOKEN 'abc def\\\\'ghi\\\\\\\\path\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_powershell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; Remove-Item /\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"TOKEN\", value),\n \"$env:TOKEN = 'abc def''ghi$HOME\\nnext; Remove-Item /'\"\n );\n }\n\n #[test]\n fn env_encrypt_output_defaults_from_key_to_secret_key() {\n assert_eq!(\n resolve_env_encrypt_output(None, Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"GH_TOKEN_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_explicit_set_wins_over_default() {\n assert_eq!(\n resolve_env_encrypt_output(Some(\"CUSTOM_SECRET\"), Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"CUSTOM_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_prints_stdin_without_set() {\n assert_eq!(\n resolve_env_encrypt_output(None, None).unwrap(),\n EnvEncryptOutput::Print\n );\n }\n\n #[test]\n fn env_encrypt_output_rejects_invalid_inferred_from_key() {\n let err = resolve_env_encrypt_output(None, Some(\"GH-TOKEN\")).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"env export key must contain only letters, digits, and underscores\"),\n \"error should explain invalid inferred key: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_backend_cli_wins_over_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, Some(\"gpg\")).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_backend_falls_back_to_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Age\n );\n }\n\n #[test]\n fn encrypt_backend_defaults_to_gpg() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_recipients_cli_wins_over_config_for_gpg() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"cli@example.com\".to_string()], &config)\n .unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"cli@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"config@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_treats_empty_config_as_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\" \".to_string());\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_gpg_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_age_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.age_recipients = vec![\"age1qexample\".to_string()];\n\n let recipients = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Age(values) => assert_eq!(values, vec![\"age1qexample\"]),\n EncryptRecipients::Gpg(_) => panic!(\"expected age recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_age_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap_err();\n\n assert!(\n err.to_string().contains(\"age recipients are required\"),\n \"error should explain how to set age recipients: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_hints_when_age_recipient_used_with_gpg_backend() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"age1qexample\".to_string()], &config)\n .unwrap_err();\n\n assert!(\n err.to_string().contains(\"did you mean --backend age\"),\n \"error should hint at the age backend: {err:#}\"\n );\n }\n\n fn shadow_key(\n config: &mut Config,\n key: &str,\n path: std::path::PathBuf,\n is_managed_overlay: bool,\n ) {\n let kind = if is_managed_overlay {\n crate::config::EnvOverrideKind::Overlay\n } else {\n crate::config::EnvOverrideKind::Global\n };\n config.env_override_sources.insert(\n key.to_string(),\n crate::config::EnvOverrideSource {\n path,\n kind,\n is_managed_overlay,\n },\n );\n }\n\n #[test]\n fn resolve_env_write_target_returns_config_toml_when_unshadowed() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", false).unwrap();\n\n assert!(matches!(target, EnvWriteTarget::ConfigToml));\n }\n\n #[test]\n fn resolve_env_write_target_refuses_without_force_when_shadowed() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = resolve_env_write_target(&config, \"MY_TOKEN\", false).unwrap_err();\n\n assert!(\n err.to_string().contains(override_path.to_str().unwrap()),\n \"error should name the winning override file: {err:#}\"\n );\n assert!(\n err.to_string().contains(\"--force\"),\n \"error should hint at --force: {err:#}\"\n );\n }\n\n #[test]\n fn resolve_env_write_target_returns_override_file_with_force() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", true).unwrap();\n\n match target {\n EnvWriteTarget::OverrideFile(source) => assert_eq!(source.path, override_path),\n EnvWriteTarget::ConfigToml => panic!(\"expected the shadowing override file\"),\n }\n }\n\n #[test]\n fn resolve_env_write_target_allows_managed_overlay_with_force() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let overlay_path = dir.join(\"overlay\").join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", overlay_path.clone(), true);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", true).unwrap();\n\n match target {\n EnvWriteTarget::OverrideFile(source) => {\n assert_eq!(source.path, overlay_path);\n assert!(source.is_managed_overlay);\n }\n EnvWriteTarget::ConfigToml => panic!(\"expected the managed overlay override file\"),\n }\n }\n\n #[tokio::test]\n async fn env_set_refuses_when_shadowed_without_force() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = handle_set(&config, \"MY_TOKEN\", \"newval\", false)\n .await\n .unwrap_err();\n\n assert!(err.to_string().contains(override_path.to_str().unwrap()));\n assert!(\n !fs::try_exists(&override_path).await.unwrap(),\n \"refused write must not touch the override file\"\n );\n assert!(\n !fs::read_to_string(config.config_path())\n .await\n .unwrap_or_default()\n .contains(\"MY_TOKEN\"),\n \"refused write must not touch config.toml either\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_set_writes_into_override_file_when_forced() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"old\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n handle_set(&config, \"MY_TOKEN\", \"newval\", true)\n .await\n .unwrap();\n\n let content = fs::read_to_string(&override_path).await.unwrap();\n assert!(content.contains(\"MY_TOKEN = \\\"newval\\\"\"));\n assert!(\n !fs::read_to_string(config.config_path())\n .await\n .unwrap_or_default()\n .contains(\"MY_TOKEN\"),\n \"forced write must go into the override file, not config.toml\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_refuses_when_shadowed_without_force() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"secret\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = handle_delete(&config, \"MY_TOKEN\", false).await.unwrap_err();\n\n assert!(err.to_string().contains(override_path.to_str().unwrap()));\n let content = fs::read_to_string(&override_path).await.unwrap();\n assert!(\n content.contains(\"MY_TOKEN\"),\n \"refused delete must leave the override file untouched\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_removes_from_override_file_when_forced() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"secret\\\"\\nOTHER = \\\"kept\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n handle_delete(&config, \"MY_TOKEN\", true).await.unwrap();\n\n let content = fs::read_to_string(&override_path).await.unwrap();\n let table: toml::Table = toml::from_str(&content).unwrap();\n assert!(!table.contains_key(\"MY_TOKEN\"));\n assert!(table.contains_key(\"OTHER\"));\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n}\n"}, "6ac3144d8d2ab15594e159c0883c8df325664807": {"t": 1785750188.6997545, "n": 41148, "h": "0ead4e57c5d507b3", "c": "//! Handlers for `shine env show/set/delete/get/decrypt/export/encrypt`.\n\nuse anyhow::{Context, Result, bail};\n\nuse super::{EnvConfig, StoredValue, resolve_stored_value, secret_key};\nuse crate::config::{Config, EnvOverrideKind, EnvOverrideSource};\nuse crate::secret::{BackendKind, EncryptRecipients};\nuse crate::{colors, path_display, secret, shells};\n\n/// Which layer supplied a variable's effective value, used to group the\n/// `env show` output. `Config` is the `config.toml [env]` table (global or\n/// project, deliberately not distinguished); the rest are `shine.env.toml`\n/// override files. Ordering matches display order (`config.toml` first, then\n/// override layers low-to-high by precedence).\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\nenum EnvSourceGroup {\n Config,\n Global,\n Overlay { managed: bool },\n Project,\n}\n\nimpl EnvSourceGroup {\n /// Fixed display order. Lower sorts first.\n fn order(self) -> u8 {\n match self {\n EnvSourceGroup::Config => 0,\n EnvSourceGroup::Global => 1,\n EnvSourceGroup::Overlay { .. } => 2,\n EnvSourceGroup::Project => 3,\n }\n }\n\n /// The bold header label for this section.\n fn label(self) -> &'static str {\n match self {\n EnvSourceGroup::Config => \"config.toml\",\n EnvSourceGroup::Global => \"global env file\",\n EnvSourceGroup::Overlay { managed: false } => \"overlay\",\n EnvSourceGroup::Overlay { managed: true } => \"overlay (managed)\",\n EnvSourceGroup::Project => \"project env file\",\n }\n }\n}\n\n/// Classify a key by its override source: no override → `config.toml [env]`,\n/// otherwise the override file's layer (folding `is_managed_overlay` into the\n/// `Overlay` variant).\nfn env_source_group(source: Option<&EnvOverrideSource>) -> EnvSourceGroup {\n match source {\n None => EnvSourceGroup::Config,\n Some(source) => match source.kind {\n EnvOverrideKind::Global => EnvSourceGroup::Global,\n EnvOverrideKind::Overlay => EnvSourceGroup::Overlay {\n managed: source.is_managed_overlay,\n },\n EnvOverrideKind::Project => EnvSourceGroup::Project,\n },\n }\n}\n\n/// Partition `keys` into ordered, non-empty sections by source group,\n/// preserving each key's relative order within its group. Pure over a\n/// `source_of` lookup so it can be unit-tested without terminal/config I/O.\nfn group_env_keys<'a>(\n keys: impl Iterator<Item = &'a str>,\n source_of: impl Fn(&str) -> Option<&'a EnvOverrideSource>,\n) -> Vec<(EnvSourceGroup, Vec<&'a str>)> {\n let mut groups: Vec<(EnvSourceGroup, Vec<&'a str>)> = Vec::new();\n for key in keys {\n let group = env_source_group(source_of(key));\n match groups.iter_mut().find(|(g, _)| *g == group) {\n Some((_, members)) => members.push(key),\n None => groups.push((group, vec![key])),\n }\n }\n groups.sort_by_key(|(group, _)| group.order());\n groups\n}\n\npub async fn handle_show(config: &Config, reveal: bool) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let catalog = super::catalog::load(config).await?;\n let terminal_width = usize::from(console::Term::stdout().size().1).max(40);\n let key_width = env\n .iter()\n .map(|(key, _)| key.chars().count())\n .max()\n .unwrap_or(0);\n\n println!(\"{}\", colors::bold(\"Environment\"));\n println!();\n if env.as_map().is_empty() {\n println!(\" {}\", colors::dim(\"No variables configured.\"));\n println!();\n }\n\n let groups = group_env_keys(env.iter().map(|(k, _)| k), |key| {\n config.env_override_source(key)\n });\n for (group, keys) in groups {\n // Header: bold label, plus the override file's path for non-config groups.\n match keys.first().and_then(|key| config.env_override_source(key)) {\n Some(source) => println!(\n \"{} {}\",\n colors::bold(group.label()),\n colors::dim(&path_display::format(&source.path))\n ),\n None => println!(\"{}\", colors::bold(group.label())),\n }\n for k in keys {\n let v = env.get(k).unwrap_or_default();\n let metadata = catalog.get(k);\n let description = env\n .description(k)\n .or_else(|| metadata.map(|item| item.description.as_str()))\n .unwrap_or_default();\n let sensitive = metadata.is_some_and(|item| item.sensitive) || is_sensitive_env_key(k);\n let display_value = display_env_value(v, sensitive, reveal);\n let (display_value, description) =\n fit_env_row(&display_value, description, key_width, terminal_width);\n let key_padding = \" \".repeat(key_width.saturating_sub(k.chars().count()));\n if description.is_empty() {\n println!(\" {}{} {}\", colors::cyan(k), key_padding, display_value);\n } else {\n println!(\n \" {}{} {:<value_width$} {}\",\n colors::cyan(k),\n key_padding,\n display_value,\n colors::dim(&description),\n value_width = env_value_width(key_width, terminal_width),\n );\n }\n }\n println!();\n }\n\n println!(\n \" {} {}\",\n colors::dim(\"Config\"),\n colors::dim(&path_display::format(config.config_path()))\n );\n println!(\n \" {}\",\n colors::dim(&format!(\"{} variables\", env.as_map().len()))\n );\n Ok(())\n}\n\nfn is_sensitive_env_key(key: &str) -> bool {\n let key = key.to_ascii_uppercase();\n [\n \"SECRET\",\n \"TOKEN\",\n \"PASSWORD\",\n \"PASSPHRASE\",\n \"API_KEY\",\n \"PRIVATE_KEY\",\n \"ACCESS_KEY\",\n ]\n .iter()\n .any(|suffix| key == *suffix || key.ends_with(&format!(\"_{suffix}\")))\n}\n\nfn display_env_value(value: &str, sensitive: bool, reveal: bool) -> String {\n if value.is_empty() {\n \"<empty>\".to_string()\n } else if sensitive && !reveal {\n \"<redacted>\".to_string()\n } else {\n value.to_string()\n }\n}\n\nfn env_value_width(key_width: usize, terminal_width: usize) -> usize {\n terminal_width.saturating_sub(key_width + 28).clamp(12, 36)\n}\n\nfn fit_env_row(\n value: &str,\n description: &str,\n key_width: usize,\n terminal_width: usize,\n) -> (String, String) {\n let value_width = env_value_width(key_width, terminal_width);\n let value = truncate_text(value, value_width);\n let description_width = terminal_width.saturating_sub(2 + key_width + 2 + value_width + 2);\n let description = if description_width < 8 {\n String::new()\n } else {\n truncate_text(description, description_width)\n };\n (value, description)\n}\n\nfn truncate_text(value: &str, max_width: usize) -> String {\n if value.chars().count() <= max_width {\n return value.to_string();\n }\n if max_width <= 1 {\n return \"…\".to_string();\n }\n let mut result = value.chars().take(max_width - 1).collect::<String>();\n result.push('…');\n result\n}\n\n/// Where an `env set`/`encrypt`/`delete` write should land: `config.toml [env]`\n/// (the default, unshadowed case), or a specific override file that already\n/// supplies the key's effective value.\n#[derive(Debug)]\nenum EnvWriteTarget<'a> {\n ConfigToml,\n OverrideFile(&'a crate::config::EnvOverrideSource),\n}\n\n/// Decide where a write to `key` should go. Refuses (unless `force`) when an\n/// override file already shadows `config.toml [env]` for this key, since a\n/// plain write there would silently have no effect on the resolved value. With\n/// `force`, warns loudly when the winning file is the shine-managed overlay\n/// mirror, since that write will be discarded on the next `shine pull`.\nfn resolve_env_write_target<'a>(\n config: &'a Config,\n key: &str,\n force: bool,\n) -> Result<EnvWriteTarget<'a>> {\n let Some(source) = config.env_override_source(key) else {\n return Ok(EnvWriteTarget::ConfigToml);\n };\n if !force {\n bail!(\n \"{key} currently resolves from {} (an env override file), which takes precedence over {}; this write would have no effect.\\nRe-run with --force to write directly into that file instead.\",\n path_display::format(&source.path),\n path_display::format(config.config_path()),\n );\n }\n if source.is_managed_overlay {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\n \"Warning: {} is the shine-managed overlay mirror; this change will be discarded on the next `shine pull`/`shine update`. Edit it upstream on the maintaining device instead.\",\n path_display::format(&source.path)\n ))\n );\n }\n Ok(EnvWriteTarget::OverrideFile(source))\n}\n\npub async fn handle_set(config: &Config, key: &str, value: &str, force: bool) -> Result<()> {\n match resolve_env_write_target(config, key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(key, value);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{value}\\\" in {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, key, Some(value)).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{value}\\\" in {}\",\n path_display::format(&source.path)\n ))\n );\n }\n }\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_delete(config: &Config, key: &str, force: bool) -> Result<()> {\n if !config.env.contains_key(key) && config.env_override_source(key).is_none() {\n bail!(\"{key} is not set in the active config [env]\");\n }\n match resolve_env_write_target(config, key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.remove(key);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"deleted {key} from {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, key, None).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"deleted {key} from {}\",\n path_display::format(&source.path)\n ))\n );\n }\n }\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_get(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n match env.get(key) {\n Some(v) => println!(\"{v}\"),\n None => {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\"{key} is not set in the active config [env]\"))\n );\n std::process::exit(1);\n }\n }\n Ok(())\n}\n\npub async fn handle_decrypt(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n let plaintext = secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {key}\"))?;\n print!(\"{plaintext}\");\n Ok(())\n}\n\npub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {\n validate_env_export_key(key)?;\n if let Some(alias) = alias {\n validate_env_export_key(alias)?;\n }\n let env = EnvConfig::load_or_init(config).await?;\n let value = match resolve_env_export_value(&env, key)? {\n EnvExportValue::Secret {\n key: secret_key,\n value,\n } => secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {secret_key}\"))?,\n EnvExportValue::Plaintext(value) => value.to_string(),\n };\n let export_as = alias.unwrap_or(key);\n println!(\n \"{}\",\n format_env_export(&config.shell_type, export_as, &value)\n );\n Ok(())\n}\n\ntype EnvExportValue<'a> = StoredValue<'a>;\n\nfn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {\n resolve_stored_value(env, key)\n}\n\nfn env_export_secret_key(key: &str) -> String {\n secret_key(key)\n}\n\nfn validate_env_export_key(key: &str) -> Result<()> {\n let mut chars = key.chars();\n let Some(first) = chars.next() else {\n bail!(\"env export key must not be empty\");\n };\n if !(first == '_' || first.is_ascii_alphabetic()) {\n bail!(\"env export key must start with a letter or underscore: {key}\");\n }\n if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {\n bail!(\"env export key must contain only letters, digits, and underscores: {key}\");\n }\n Ok(())\n}\n\n/// `pub(crate)` so `theme::handle_sync` can reuse the same per-shell quoting\n/// instead of adding a fourth `single_quote` implementation to the codebase\n/// (see docs/terminal-theme-sync-prd.md §7/§10).\npub(crate) fn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {\n match shell {\n shells::ShellType::Fish => format!(\"set -gx {key} {}\", fish_quote(value)),\n shells::ShellType::PowerShell => {\n format!(\"$env:{key} = {}\", powershell_string_quote(value))\n }\n _ => format!(\"export {key}={}\", posix_shell_quote(value)),\n }\n}\n\nfn posix_shell_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"'\\\\''\"))\n}\n\nfn fish_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\\\', \"\\\\\\\\\").replace('\\'', \"\\\\'\"))\n}\n\nfn powershell_string_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"''\"))\n}\n\n#[derive(Debug, PartialEq, Eq)]\nenum EnvEncryptOutput {\n Print,\n Set(String),\n}\n\nfn resolve_env_encrypt_output(\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<EnvEncryptOutput> {\n if let Some(key) = set_key {\n return Ok(EnvEncryptOutput::Set(key.to_string()));\n }\n if let Some(key) = from_key {\n validate_env_export_key(key)?;\n return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));\n }\n Ok(EnvEncryptOutput::Print)\n}\n\nfn resolve_encrypt_backend(config: &Config, backend: Option<&str>) -> Result<BackendKind> {\n if let Some(backend) = backend.map(str::trim).filter(|value| !value.is_empty()) {\n return backend.parse();\n }\n if let Some(backend) = config\n .secret_backend\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n {\n return backend.parse();\n }\n Ok(BackendKind::default())\n}\n\nfn clean_recipients(recipients: &[String]) -> Vec<String> {\n recipients\n .iter()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .collect()\n}\n\nfn resolve_encrypt_recipients(\n backend: BackendKind,\n cli_recipients: &[String],\n config: &Config,\n) -> Result<EncryptRecipients> {\n let cli_recipients = clean_recipients(cli_recipients);\n if !cli_recipients.is_empty() {\n if backend == BackendKind::Gpg\n && let Some(hint) = cli_recipients\n .iter()\n .find(|value| value.starts_with(\"age1\"))\n {\n bail!(\"recipient \\\"{hint}\\\" looks like an age recipient; did you mean --backend age?\");\n }\n return Ok(match backend {\n BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),\n BackendKind::Age => EncryptRecipients::Age(cli_recipients),\n });\n }\n\n match backend {\n BackendKind::Gpg => {\n let recipient = config\n .gpg_key_id\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .context(\n \"GPG recipient is required; pass -r/--recipient, set gpg_key_id, or set secret_backend/age_recipients for age\",\n )?;\n Ok(EncryptRecipients::Gpg(vec![recipient.to_string()]))\n }\n BackendKind::Age => {\n let recipients = clean_recipients(&config.age_recipients);\n if recipients.is_empty() {\n bail!(\n \"age recipients are required; pass -r/--recipient or set age_recipients in config.toml\"\n );\n }\n Ok(EncryptRecipients::Age(recipients))\n }\n }\n}\n\npub async fn handle_encrypt(\n config: &Config,\n backend: Option<&str>,\n recipients: &[String],\n set_key: Option<&str>,\n from_key: Option<&str>,\n force: bool,\n) -> Result<()> {\n use std::io::Read as _;\n\n let backend = resolve_encrypt_backend(config, backend)?;\n let recipients = resolve_encrypt_recipients(backend, recipients, config)?;\n let plaintext = if let Some(key) = from_key {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n value.as_bytes().to_vec()\n } else {\n let mut input = Vec::new();\n std::io::stdin()\n .read_to_end(&mut input)\n .context(\"reading secret from stdin\")?;\n input\n };\n let encoded = secret::encrypt_secret(&plaintext, &recipients)\n .await\n .context(\"encrypting secret\")?;\n match resolve_env_encrypt_output(set_key, from_key)? {\n EnvEncryptOutput::Set(key) => match resolve_env_write_target(config, &key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(&key, &encoded);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{encoded}\\\" in {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, &key, Some(&encoded)).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{encoded}\\\" in {}\",\n path_display::format(&source.path)\n ))\n );\n }\n },\n EnvEncryptOutput::Print => println!(\"{encoded}\"),\n }\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::config::Config;\n use tokio::fs;\n\n async fn make_temp_dir() -> std::path::PathBuf {\n crate::test_support::make_temp_dir(\"shine-env-cmd-test\").await\n }\n\n fn config_in(dir: &std::path::Path) -> Config {\n crate::test_support::test_config(dir)\n }\n\n #[test]\n fn env_show_redacts_sensitive_values() {\n assert_eq!(display_env_value(\"secret\", true, false), \"<redacted>\");\n assert_eq!(display_env_value(\"secret\", true, true), \"secret\");\n assert_eq!(display_env_value(\"\", true, false), \"<empty>\");\n assert!(is_sensitive_env_key(\"MY_API_KEY\"));\n assert!(is_sensitive_env_key(\"token\"));\n assert!(!is_sensitive_env_key(\"MONKEY\"));\n }\n\n fn source(kind: EnvOverrideKind, managed: bool) -> EnvOverrideSource {\n EnvOverrideSource {\n path: std::path::PathBuf::from(\"/tmp/shine.env.toml\"),\n kind,\n is_managed_overlay: managed,\n }\n }\n\n #[test]\n fn env_source_group_maps_each_layer() {\n assert_eq!(env_source_group(None), EnvSourceGroup::Config);\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Global, false))),\n EnvSourceGroup::Global\n );\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Overlay, false))),\n EnvSourceGroup::Overlay { managed: false }\n );\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Overlay, true))),\n EnvSourceGroup::Overlay { managed: true }\n );\n assert_eq!(\n env_source_group(Some(&source(EnvOverrideKind::Project, false))),\n EnvSourceGroup::Project\n );\n }\n\n #[test]\n fn group_env_keys_orders_sections_and_skips_empty() {\n let global = source(EnvOverrideKind::Global, false);\n let overlay = source(EnvOverrideKind::Overlay, true);\n // Keys deliberately out of source order; config keys have no override.\n let keys = [\"PROJECT_LESS\", \"FROM_OVERLAY\", \"FROM_CONFIG\", \"FROM_GLOBAL\"];\n let groups = group_env_keys(keys.iter().copied(), |key| match key {\n \"FROM_GLOBAL\" => Some(&global),\n \"FROM_OVERLAY\" => Some(&overlay),\n _ => None,\n });\n\n // Only Config, Global, Overlay are present (Project skipped), in order.\n assert_eq!(\n groups.iter().map(|(g, _)| *g).collect::<Vec<_>>(),\n vec![\n EnvSourceGroup::Config,\n EnvSourceGroup::Global,\n EnvSourceGroup::Overlay { managed: true },\n ]\n );\n assert_eq!(groups[0].1, vec![\"PROJECT_LESS\", \"FROM_CONFIG\"]);\n assert_eq!(groups[1].1, vec![\"FROM_GLOBAL\"]);\n assert_eq!(groups[2].1, vec![\"FROM_OVERLAY\"]);\n }\n\n #[test]\n fn group_env_keys_all_config_yields_single_group() {\n let keys = [\"A\", \"B\", \"C\"];\n let groups = group_env_keys(keys.iter().copied(), |_| None);\n assert_eq!(groups.len(), 1);\n assert_eq!(groups[0].0, EnvSourceGroup::Config);\n assert_eq!(groups[0].1, vec![\"A\", \"B\", \"C\"]);\n }\n\n #[test]\n fn env_source_group_labels_are_stable() {\n assert_eq!(EnvSourceGroup::Config.label(), \"config.toml\");\n assert_eq!(EnvSourceGroup::Global.label(), \"global env file\");\n assert_eq!(\n EnvSourceGroup::Overlay { managed: false }.label(),\n \"overlay\"\n );\n assert_eq!(\n EnvSourceGroup::Overlay { managed: true }.label(),\n \"overlay (managed)\"\n );\n assert_eq!(EnvSourceGroup::Project.label(), \"project env file\");\n }\n\n #[test]\n fn env_show_truncates_long_values_to_requested_width() {\n assert_eq!(truncate_text(\"abcdefgh\", 5), \"abcd…\");\n let (value, description) = fit_env_row(\n \"abcdefghijklmnopqrstuvwxyz\",\n \"A description that is also fairly long\",\n 8,\n 48,\n );\n assert!(value.chars().count() <= env_value_width(8, 48));\n assert!(description.chars().count() <= 48);\n }\n\n #[test]\n fn env_export_uses_alias_as_variable_name() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"MY_ALIAS\", value),\n \"export MY_ALIAS='secret123'\"\n );\n }\n\n #[test]\n fn env_export_alias_formats_powershell_correctly() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"MY_ALIAS\", value),\n \"$env:MY_ALIAS = 'secret123'\"\n );\n }\n\n #[tokio::test]\n async fn env_delete_removes_key_from_saved_config() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n config.env.insert(\"MY_TOKEN\".into(), \"secret\".into());\n config.save().await.unwrap();\n\n handle_delete(&config, \"MY_TOKEN\", false).await.unwrap();\n\n let contents = fs::read_to_string(config.config_path()).await.unwrap();\n let parsed: toml::Table = toml::from_str(&contents).unwrap();\n let env = parsed\n .get(\"env\")\n .and_then(|value| value.as_table())\n .unwrap();\n assert!(\n !env.contains_key(\"MY_TOKEN\"),\n \"deleted key should not remain in saved config: {contents}\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_fails_when_key_is_missing() {\n let dir = make_temp_dir().await;\n let config = config_in(&dir);\n\n let err = handle_delete(&config, \"MY_TOKEN\", false).await.unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN is not set in the active config [env]\"),\n \"error should explain missing key: {err:#}\"\n );\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[test]\n fn env_export_secret_key_appends_secret_suffix() {\n assert_eq!(\n env_export_secret_key(\"DEEPSEEK_API_KEY\"),\n \"DEEPSEEK_API_KEY_SECRET\"\n );\n assert_eq!(env_export_secret_key(\"xxx\"), \"xxx_SECRET\");\n }\n\n #[test]\n fn env_export_resolves_secret_when_present() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_falls_back_to_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Plaintext(\"plain\")\n );\n }\n\n #[test]\n fn env_export_secret_wins_over_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_reports_both_missing_keys() {\n let env = EnvConfig::default();\n\n let err = resolve_env_export_value(&env, \"MY_TOKEN\").unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]\"),\n \"error should explain both checked keys: {err:#}\"\n );\n }\n\n #[test]\n fn env_export_key_validation_accepts_shell_variable_names() {\n for key in [\"FOO\", \"_FOO\", \"foo_123\", \"A1\"] {\n validate_env_export_key(key).unwrap();\n }\n }\n\n #[test]\n fn env_export_key_validation_rejects_unsafe_names() {\n for key in [\"\", \"1FOO\", \"FOO-BAR\", \"FOO;BAR\", \"FOO BAR\", \"FOO.SECRET\"] {\n assert!(\n validate_env_export_key(key).is_err(),\n \"key should be rejected: {key}\"\n );\n }\n }\n\n #[test]\n fn env_export_formats_posix_shell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"TOKEN\", value),\n \"export TOKEN='abc def'\\\\''ghi$HOME\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_fish_shell_code_safely() {\n let value = \"abc def'ghi\\\\path\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Fish, \"TOKEN\", value),\n \"set -gx TOKEN 'abc def\\\\'ghi\\\\\\\\path\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_powershell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; Remove-Item /\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"TOKEN\", value),\n \"$env:TOKEN = 'abc def''ghi$HOME\\nnext; Remove-Item /'\"\n );\n }\n\n #[test]\n fn env_encrypt_output_defaults_from_key_to_secret_key() {\n assert_eq!(\n resolve_env_encrypt_output(None, Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"GH_TOKEN_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_explicit_set_wins_over_default() {\n assert_eq!(\n resolve_env_encrypt_output(Some(\"CUSTOM_SECRET\"), Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"CUSTOM_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_prints_stdin_without_set() {\n assert_eq!(\n resolve_env_encrypt_output(None, None).unwrap(),\n EnvEncryptOutput::Print\n );\n }\n\n #[test]\n fn env_encrypt_output_rejects_invalid_inferred_from_key() {\n let err = resolve_env_encrypt_output(None, Some(\"GH-TOKEN\")).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"env export key must contain only letters, digits, and underscores\"),\n \"error should explain invalid inferred key: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_backend_cli_wins_over_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, Some(\"gpg\")).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_backend_falls_back_to_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Age\n );\n }\n\n #[test]\n fn encrypt_backend_defaults_to_gpg() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_recipients_cli_wins_over_config_for_gpg() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"cli@example.com\".to_string()], &config)\n .unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"cli@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"config@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_treats_empty_config_as_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\" \".to_string());\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_gpg_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_age_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.age_recipients = vec![\"age1qexample\".to_string()];\n\n let recipients = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Age(values) => assert_eq!(values, vec![\"age1qexample\"]),\n EncryptRecipients::Gpg(_) => panic!(\"expected age recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_age_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap_err();\n\n assert!(\n err.to_string().contains(\"age recipients are required\"),\n \"error should explain how to set age recipients: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_hints_when_age_recipient_used_with_gpg_backend() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"age1qexample\".to_string()], &config)\n .unwrap_err();\n\n assert!(\n err.to_string().contains(\"did you mean --backend age\"),\n \"error should hint at the age backend: {err:#}\"\n );\n }\n\n fn shadow_key(\n config: &mut Config,\n key: &str,\n path: std::path::PathBuf,\n is_managed_overlay: bool,\n ) {\n let kind = if is_managed_overlay {\n crate::config::EnvOverrideKind::Overlay\n } else {\n crate::config::EnvOverrideKind::Global\n };\n config.env_override_sources.insert(\n key.to_string(),\n crate::config::EnvOverrideSource {\n path,\n kind,\n is_managed_overlay,\n },\n );\n }\n\n #[test]\n fn resolve_env_write_target_returns_config_toml_when_unshadowed() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", false).unwrap();\n\n assert!(matches!(target, EnvWriteTarget::ConfigToml));\n }\n\n #[test]\n fn resolve_env_write_target_refuses_without_force_when_shadowed() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = resolve_env_write_target(&config, \"MY_TOKEN\", false).unwrap_err();\n\n assert!(\n err.to_string().contains(override_path.to_str().unwrap()),\n \"error should name the winning override file: {err:#}\"\n );\n assert!(\n err.to_string().contains(\"--force\"),\n \"error should hint at --force: {err:#}\"\n );\n }\n\n #[test]\n fn resolve_env_write_target_returns_override_file_with_force() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", true).unwrap();\n\n match target {\n EnvWriteTarget::OverrideFile(source) => assert_eq!(source.path, override_path),\n EnvWriteTarget::ConfigToml => panic!(\"expected the shadowing override file\"),\n }\n }\n\n #[test]\n fn resolve_env_write_target_allows_managed_overlay_with_force() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let overlay_path = dir.join(\"overlay\").join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", overlay_path.clone(), true);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", true).unwrap();\n\n match target {\n EnvWriteTarget::OverrideFile(source) => {\n assert_eq!(source.path, overlay_path);\n assert!(source.is_managed_overlay);\n }\n EnvWriteTarget::ConfigToml => panic!(\"expected the managed overlay override file\"),\n }\n }\n\n #[tokio::test]\n async fn env_set_refuses_when_shadowed_without_force() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = handle_set(&config, \"MY_TOKEN\", \"newval\", false)\n .await\n .unwrap_err();\n\n assert!(err.to_string().contains(override_path.to_str().unwrap()));\n assert!(\n !fs::try_exists(&override_path).await.unwrap(),\n \"refused write must not touch the override file\"\n );\n assert!(\n !fs::read_to_string(config.config_path())\n .await\n .unwrap_or_default()\n .contains(\"MY_TOKEN\"),\n \"refused write must not touch config.toml either\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_set_writes_into_override_file_when_forced() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"old\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n handle_set(&config, \"MY_TOKEN\", \"newval\", true)\n .await\n .unwrap();\n\n let content = fs::read_to_string(&override_path).await.unwrap();\n assert!(content.contains(\"MY_TOKEN = \\\"newval\\\"\"));\n assert!(\n !fs::read_to_string(config.config_path())\n .await\n .unwrap_or_default()\n .contains(\"MY_TOKEN\"),\n \"forced write must go into the override file, not config.toml\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_refuses_when_shadowed_without_force() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"secret\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = handle_delete(&config, \"MY_TOKEN\", false).await.unwrap_err();\n\n assert!(err.to_string().contains(override_path.to_str().unwrap()));\n let content = fs::read_to_string(&override_path).await.unwrap();\n assert!(\n content.contains(\"MY_TOKEN\"),\n \"refused delete must leave the override file untouched\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_removes_from_override_file_when_forced() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"secret\\\"\\nOTHER = \\\"kept\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n handle_delete(&config, \"MY_TOKEN\", true).await.unwrap();\n\n let content = fs::read_to_string(&override_path).await.unwrap();\n let table: toml::Table = toml::from_str(&content).unwrap();\n assert!(!table.contains_key(\"MY_TOKEN\"));\n assert!(table.contains_key(\"OTHER\"));\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n}\n"}, "f1fea8df0c948a49cbf6e3cb6731c4a9e37db1bf": {"t": 1785750190.8288734, "n": 34577, "h": "db6b5ac343f7d0c0", "c": "//! Handlers for `shine env show/set/delete/get/decrypt/export/encrypt`.\n\nuse anyhow::{Context, Result, bail};\n\nuse super::{EnvConfig, StoredValue, resolve_stored_value, secret_key};\nuse crate::config::Config;\nuse crate::secret::{BackendKind, EncryptRecipients};\nuse crate::{colors, path_display, secret, shells};\n\npub async fn handle_show(config: &Config, reveal: bool) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let catalog = super::catalog::load(config).await?;\n let terminal_width = usize::from(console::Term::stdout().size().1).max(40);\n let key_width = env\n .iter()\n .map(|(key, _)| key.chars().count())\n .max()\n .unwrap_or(0);\n\n println!(\"{}\", colors::bold(\"Environment\"));\n println!();\n if env.as_map().is_empty() {\n println!(\" {}\", colors::dim(\"No variables configured.\"));\n }\n for (k, v) in env.iter() {\n let metadata = catalog.get(k);\n let description = env\n .description(k)\n .or_else(|| metadata.map(|item| item.description.as_str()))\n .unwrap_or_default();\n let sensitive = metadata.is_some_and(|item| item.sensitive) || is_sensitive_env_key(k);\n let display_value = display_env_value(v, sensitive, reveal);\n let (display_value, description) =\n fit_env_row(&display_value, description, key_width, terminal_width);\n let key_padding = \" \".repeat(key_width.saturating_sub(k.chars().count()));\n if description.is_empty() {\n println!(\" {}{} {}\", colors::cyan(k), key_padding, display_value);\n } else {\n println!(\n \" {}{} {:<value_width$} {}\",\n colors::cyan(k),\n key_padding,\n display_value,\n colors::dim(&description),\n value_width = env_value_width(key_width, terminal_width),\n );\n }\n }\n println!();\n println!(\n \" {} {}\",\n colors::dim(\"Config\"),\n colors::dim(&path_display::format(config.config_path()))\n );\n println!(\n \" {}\",\n colors::dim(&format!(\"{} variables\", env.as_map().len()))\n );\n Ok(())\n}\n\nfn is_sensitive_env_key(key: &str) -> bool {\n let key = key.to_ascii_uppercase();\n [\n \"SECRET\",\n \"TOKEN\",\n \"PASSWORD\",\n \"PASSPHRASE\",\n \"API_KEY\",\n \"PRIVATE_KEY\",\n \"ACCESS_KEY\",\n ]\n .iter()\n .any(|suffix| key == *suffix || key.ends_with(&format!(\"_{suffix}\")))\n}\n\nfn display_env_value(value: &str, sensitive: bool, reveal: bool) -> String {\n if value.is_empty() {\n \"<empty>\".to_string()\n } else if sensitive && !reveal {\n \"<redacted>\".to_string()\n } else {\n value.to_string()\n }\n}\n\nfn env_value_width(key_width: usize, terminal_width: usize) -> usize {\n terminal_width.saturating_sub(key_width + 28).clamp(12, 36)\n}\n\nfn fit_env_row(\n value: &str,\n description: &str,\n key_width: usize,\n terminal_width: usize,\n) -> (String, String) {\n let value_width = env_value_width(key_width, terminal_width);\n let value = truncate_text(value, value_width);\n let description_width = terminal_width.saturating_sub(2 + key_width + 2 + value_width + 2);\n let description = if description_width < 8 {\n String::new()\n } else {\n truncate_text(description, description_width)\n };\n (value, description)\n}\n\nfn truncate_text(value: &str, max_width: usize) -> String {\n if value.chars().count() <= max_width {\n return value.to_string();\n }\n if max_width <= 1 {\n return \"…\".to_string();\n }\n let mut result = value.chars().take(max_width - 1).collect::<String>();\n result.push('…');\n result\n}\n\n/// Where an `env set`/`encrypt`/`delete` write should land: `config.toml [env]`\n/// (the default, unshadowed case), or a specific override file that already\n/// supplies the key's effective value.\n#[derive(Debug)]\nenum EnvWriteTarget<'a> {\n ConfigToml,\n OverrideFile(&'a crate::config::EnvOverrideSource),\n}\n\n/// Decide where a write to `key` should go. Refuses (unless `force`) when an\n/// override file already shadows `config.toml [env]` for this key, since a\n/// plain write there would silently have no effect on the resolved value. With\n/// `force`, warns loudly when the winning file is the shine-managed overlay\n/// mirror, since that write will be discarded on the next `shine pull`.\nfn resolve_env_write_target<'a>(\n config: &'a Config,\n key: &str,\n force: bool,\n) -> Result<EnvWriteTarget<'a>> {\n let Some(source) = config.env_override_source(key) else {\n return Ok(EnvWriteTarget::ConfigToml);\n };\n if !force {\n bail!(\n \"{key} currently resolves from {} (an env override file), which takes precedence over {}; this write would have no effect.\\nRe-run with --force to write directly into that file instead.\",\n path_display::format(&source.path),\n path_display::format(config.config_path()),\n );\n }\n if source.is_managed_overlay {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\n \"Warning: {} is the shine-managed overlay mirror; this change will be discarded on the next `shine pull`/`shine update`. Edit it upstream on the maintaining device instead.\",\n path_display::format(&source.path)\n ))\n );\n }\n Ok(EnvWriteTarget::OverrideFile(source))\n}\n\npub async fn handle_set(config: &Config, key: &str, value: &str, force: bool) -> Result<()> {\n match resolve_env_write_target(config, key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(key, value);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{value}\\\" in {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, key, Some(value)).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{value}\\\" in {}\",\n path_display::format(&source.path)\n ))\n );\n }\n }\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_delete(config: &Config, key: &str, force: bool) -> Result<()> {\n if !config.env.contains_key(key) && config.env_override_source(key).is_none() {\n bail!(\"{key} is not set in the active config [env]\");\n }\n match resolve_env_write_target(config, key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.remove(key);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"deleted {key} from {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, key, None).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"deleted {key} from {}\",\n path_display::format(&source.path)\n ))\n );\n }\n }\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_get(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n match env.get(key) {\n Some(v) => println!(\"{v}\"),\n None => {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\"{key} is not set in the active config [env]\"))\n );\n std::process::exit(1);\n }\n }\n Ok(())\n}\n\npub async fn handle_decrypt(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n let plaintext = secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {key}\"))?;\n print!(\"{plaintext}\");\n Ok(())\n}\n\npub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {\n validate_env_export_key(key)?;\n if let Some(alias) = alias {\n validate_env_export_key(alias)?;\n }\n let env = EnvConfig::load_or_init(config).await?;\n let value = match resolve_env_export_value(&env, key)? {\n EnvExportValue::Secret {\n key: secret_key,\n value,\n } => secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {secret_key}\"))?,\n EnvExportValue::Plaintext(value) => value.to_string(),\n };\n let export_as = alias.unwrap_or(key);\n println!(\n \"{}\",\n format_env_export(&config.shell_type, export_as, &value)\n );\n Ok(())\n}\n\ntype EnvExportValue<'a> = StoredValue<'a>;\n\nfn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {\n resolve_stored_value(env, key)\n}\n\nfn env_export_secret_key(key: &str) -> String {\n secret_key(key)\n}\n\nfn validate_env_export_key(key: &str) -> Result<()> {\n let mut chars = key.chars();\n let Some(first) = chars.next() else {\n bail!(\"env export key must not be empty\");\n };\n if !(first == '_' || first.is_ascii_alphabetic()) {\n bail!(\"env export key must start with a letter or underscore: {key}\");\n }\n if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {\n bail!(\"env export key must contain only letters, digits, and underscores: {key}\");\n }\n Ok(())\n}\n\n/// `pub(crate)` so `theme::handle_sync` can reuse the same per-shell quoting\n/// instead of adding a fourth `single_quote` implementation to the codebase\n/// (see docs/terminal-theme-sync-prd.md §7/§10).\npub(crate) fn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {\n match shell {\n shells::ShellType::Fish => format!(\"set -gx {key} {}\", fish_quote(value)),\n shells::ShellType::PowerShell => {\n format!(\"$env:{key} = {}\", powershell_string_quote(value))\n }\n _ => format!(\"export {key}={}\", posix_shell_quote(value)),\n }\n}\n\nfn posix_shell_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"'\\\\''\"))\n}\n\nfn fish_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\\\', \"\\\\\\\\\").replace('\\'', \"\\\\'\"))\n}\n\nfn powershell_string_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"''\"))\n}\n\n#[derive(Debug, PartialEq, Eq)]\nenum EnvEncryptOutput {\n Print,\n Set(String),\n}\n\nfn resolve_env_encrypt_output(\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<EnvEncryptOutput> {\n if let Some(key) = set_key {\n return Ok(EnvEncryptOutput::Set(key.to_string()));\n }\n if let Some(key) = from_key {\n validate_env_export_key(key)?;\n return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));\n }\n Ok(EnvEncryptOutput::Print)\n}\n\nfn resolve_encrypt_backend(config: &Config, backend: Option<&str>) -> Result<BackendKind> {\n if let Some(backend) = backend.map(str::trim).filter(|value| !value.is_empty()) {\n return backend.parse();\n }\n if let Some(backend) = config\n .secret_backend\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n {\n return backend.parse();\n }\n Ok(BackendKind::default())\n}\n\nfn clean_recipients(recipients: &[String]) -> Vec<String> {\n recipients\n .iter()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .collect()\n}\n\nfn resolve_encrypt_recipients(\n backend: BackendKind,\n cli_recipients: &[String],\n config: &Config,\n) -> Result<EncryptRecipients> {\n let cli_recipients = clean_recipients(cli_recipients);\n if !cli_recipients.is_empty() {\n if backend == BackendKind::Gpg\n && let Some(hint) = cli_recipients\n .iter()\n .find(|value| value.starts_with(\"age1\"))\n {\n bail!(\"recipient \\\"{hint}\\\" looks like an age recipient; did you mean --backend age?\");\n }\n return Ok(match backend {\n BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),\n BackendKind::Age => EncryptRecipients::Age(cli_recipients),\n });\n }\n\n match backend {\n BackendKind::Gpg => {\n let recipient = config\n .gpg_key_id\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .context(\n \"GPG recipient is required; pass -r/--recipient, set gpg_key_id, or set secret_backend/age_recipients for age\",\n )?;\n Ok(EncryptRecipients::Gpg(vec![recipient.to_string()]))\n }\n BackendKind::Age => {\n let recipients = clean_recipients(&config.age_recipients);\n if recipients.is_empty() {\n bail!(\n \"age recipients are required; pass -r/--recipient or set age_recipients in config.toml\"\n );\n }\n Ok(EncryptRecipients::Age(recipients))\n }\n }\n}\n\npub async fn handle_encrypt(\n config: &Config,\n backend: Option<&str>,\n recipients: &[String],\n set_key: Option<&str>,\n from_key: Option<&str>,\n force: bool,\n) -> Result<()> {\n use std::io::Read as _;\n\n let backend = resolve_encrypt_backend(config, backend)?;\n let recipients = resolve_encrypt_recipients(backend, recipients, config)?;\n let plaintext = if let Some(key) = from_key {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n value.as_bytes().to_vec()\n } else {\n let mut input = Vec::new();\n std::io::stdin()\n .read_to_end(&mut input)\n .context(\"reading secret from stdin\")?;\n input\n };\n let encoded = secret::encrypt_secret(&plaintext, &recipients)\n .await\n .context(\"encrypting secret\")?;\n match resolve_env_encrypt_output(set_key, from_key)? {\n EnvEncryptOutput::Set(key) => match resolve_env_write_target(config, &key, force)? {\n EnvWriteTarget::ConfigToml => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(&key, &encoded);\n env.save(config).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{encoded}\\\" in {}\",\n path_display::format(config.config_path())\n ))\n );\n }\n EnvWriteTarget::OverrideFile(source) => {\n crate::config::write_env_override_entry(&source.path, &key, Some(&encoded)).await?;\n println!(\n \"{}\",\n colors::green(&format!(\n \"set {key} = \\\"{encoded}\\\" in {}\",\n path_display::format(&source.path)\n ))\n );\n }\n },\n EnvEncryptOutput::Print => println!(\"{encoded}\"),\n }\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::config::Config;\n use tokio::fs;\n\n async fn make_temp_dir() -> std::path::PathBuf {\n crate::test_support::make_temp_dir(\"shine-env-cmd-test\").await\n }\n\n fn config_in(dir: &std::path::Path) -> Config {\n crate::test_support::test_config(dir)\n }\n\n #[test]\n fn env_show_redacts_sensitive_values() {\n assert_eq!(display_env_value(\"secret\", true, false), \"<redacted>\");\n assert_eq!(display_env_value(\"secret\", true, true), \"secret\");\n assert_eq!(display_env_value(\"\", true, false), \"<empty>\");\n assert!(is_sensitive_env_key(\"MY_API_KEY\"));\n assert!(is_sensitive_env_key(\"token\"));\n assert!(!is_sensitive_env_key(\"MONKEY\"));\n }\n\n #[test]\n fn env_show_truncates_long_values_to_requested_width() {\n assert_eq!(truncate_text(\"abcdefgh\", 5), \"abcd…\");\n let (value, description) = fit_env_row(\n \"abcdefghijklmnopqrstuvwxyz\",\n \"A description that is also fairly long\",\n 8,\n 48,\n );\n assert!(value.chars().count() <= env_value_width(8, 48));\n assert!(description.chars().count() <= 48);\n }\n\n #[test]\n fn env_export_uses_alias_as_variable_name() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"MY_ALIAS\", value),\n \"export MY_ALIAS='secret123'\"\n );\n }\n\n #[test]\n fn env_export_alias_formats_powershell_correctly() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"MY_ALIAS\", value),\n \"$env:MY_ALIAS = 'secret123'\"\n );\n }\n\n #[tokio::test]\n async fn env_delete_removes_key_from_saved_config() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n config.env.insert(\"MY_TOKEN\".into(), \"secret\".into());\n config.save().await.unwrap();\n\n handle_delete(&config, \"MY_TOKEN\", false).await.unwrap();\n\n let contents = fs::read_to_string(config.config_path()).await.unwrap();\n let parsed: toml::Table = toml::from_str(&contents).unwrap();\n let env = parsed\n .get(\"env\")\n .and_then(|value| value.as_table())\n .unwrap();\n assert!(\n !env.contains_key(\"MY_TOKEN\"),\n \"deleted key should not remain in saved config: {contents}\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_fails_when_key_is_missing() {\n let dir = make_temp_dir().await;\n let config = config_in(&dir);\n\n let err = handle_delete(&config, \"MY_TOKEN\", false).await.unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN is not set in the active config [env]\"),\n \"error should explain missing key: {err:#}\"\n );\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[test]\n fn env_export_secret_key_appends_secret_suffix() {\n assert_eq!(\n env_export_secret_key(\"DEEPSEEK_API_KEY\"),\n \"DEEPSEEK_API_KEY_SECRET\"\n );\n assert_eq!(env_export_secret_key(\"xxx\"), \"xxx_SECRET\");\n }\n\n #[test]\n fn env_export_resolves_secret_when_present() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_falls_back_to_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Plaintext(\"plain\")\n );\n }\n\n #[test]\n fn env_export_secret_wins_over_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_reports_both_missing_keys() {\n let env = EnvConfig::default();\n\n let err = resolve_env_export_value(&env, \"MY_TOKEN\").unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]\"),\n \"error should explain both checked keys: {err:#}\"\n );\n }\n\n #[test]\n fn env_export_key_validation_accepts_shell_variable_names() {\n for key in [\"FOO\", \"_FOO\", \"foo_123\", \"A1\"] {\n validate_env_export_key(key).unwrap();\n }\n }\n\n #[test]\n fn env_export_key_validation_rejects_unsafe_names() {\n for key in [\"\", \"1FOO\", \"FOO-BAR\", \"FOO;BAR\", \"FOO BAR\", \"FOO.SECRET\"] {\n assert!(\n validate_env_export_key(key).is_err(),\n \"key should be rejected: {key}\"\n );\n }\n }\n\n #[test]\n fn env_export_formats_posix_shell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"TOKEN\", value),\n \"export TOKEN='abc def'\\\\''ghi$HOME\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_fish_shell_code_safely() {\n let value = \"abc def'ghi\\\\path\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Fish, \"TOKEN\", value),\n \"set -gx TOKEN 'abc def\\\\'ghi\\\\\\\\path\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_powershell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; Remove-Item /\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"TOKEN\", value),\n \"$env:TOKEN = 'abc def''ghi$HOME\\nnext; Remove-Item /'\"\n );\n }\n\n #[test]\n fn env_encrypt_output_defaults_from_key_to_secret_key() {\n assert_eq!(\n resolve_env_encrypt_output(None, Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"GH_TOKEN_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_explicit_set_wins_over_default() {\n assert_eq!(\n resolve_env_encrypt_output(Some(\"CUSTOM_SECRET\"), Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"CUSTOM_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_prints_stdin_without_set() {\n assert_eq!(\n resolve_env_encrypt_output(None, None).unwrap(),\n EnvEncryptOutput::Print\n );\n }\n\n #[test]\n fn env_encrypt_output_rejects_invalid_inferred_from_key() {\n let err = resolve_env_encrypt_output(None, Some(\"GH-TOKEN\")).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"env export key must contain only letters, digits, and underscores\"),\n \"error should explain invalid inferred key: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_backend_cli_wins_over_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, Some(\"gpg\")).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_backend_falls_back_to_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Age\n );\n }\n\n #[test]\n fn encrypt_backend_defaults_to_gpg() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_recipients_cli_wins_over_config_for_gpg() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"cli@example.com\".to_string()], &config)\n .unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"cli@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"config@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_treats_empty_config_as_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\" \".to_string());\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_gpg_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_age_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.age_recipients = vec![\"age1qexample\".to_string()];\n\n let recipients = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Age(values) => assert_eq!(values, vec![\"age1qexample\"]),\n EncryptRecipients::Gpg(_) => panic!(\"expected age recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_age_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap_err();\n\n assert!(\n err.to_string().contains(\"age recipients are required\"),\n \"error should explain how to set age recipients: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_hints_when_age_recipient_used_with_gpg_backend() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"age1qexample\".to_string()], &config)\n .unwrap_err();\n\n assert!(\n err.to_string().contains(\"did you mean --backend age\"),\n \"error should hint at the age backend: {err:#}\"\n );\n }\n\n fn shadow_key(\n config: &mut Config,\n key: &str,\n path: std::path::PathBuf,\n is_managed_overlay: bool,\n ) {\n config.env_override_sources.insert(\n key.to_string(),\n crate::config::EnvOverrideSource {\n path,\n is_managed_overlay,\n },\n );\n }\n\n #[test]\n fn resolve_env_write_target_returns_config_toml_when_unshadowed() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", false).unwrap();\n\n assert!(matches!(target, EnvWriteTarget::ConfigToml));\n }\n\n #[test]\n fn resolve_env_write_target_refuses_without_force_when_shadowed() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = resolve_env_write_target(&config, \"MY_TOKEN\", false).unwrap_err();\n\n assert!(\n err.to_string().contains(override_path.to_str().unwrap()),\n \"error should name the winning override file: {err:#}\"\n );\n assert!(\n err.to_string().contains(\"--force\"),\n \"error should hint at --force: {err:#}\"\n );\n }\n\n #[test]\n fn resolve_env_write_target_returns_override_file_with_force() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", true).unwrap();\n\n match target {\n EnvWriteTarget::OverrideFile(source) => assert_eq!(source.path, override_path),\n EnvWriteTarget::ConfigToml => panic!(\"expected the shadowing override file\"),\n }\n }\n\n #[test]\n fn resolve_env_write_target_allows_managed_overlay_with_force() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-write-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n let overlay_path = dir.join(\"overlay\").join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", overlay_path.clone(), true);\n\n let target = resolve_env_write_target(&config, \"MY_TOKEN\", true).unwrap();\n\n match target {\n EnvWriteTarget::OverrideFile(source) => {\n assert_eq!(source.path, overlay_path);\n assert!(source.is_managed_overlay);\n }\n EnvWriteTarget::ConfigToml => panic!(\"expected the managed overlay override file\"),\n }\n }\n\n #[tokio::test]\n async fn env_set_refuses_when_shadowed_without_force() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = handle_set(&config, \"MY_TOKEN\", \"newval\", false)\n .await\n .unwrap_err();\n\n assert!(err.to_string().contains(override_path.to_str().unwrap()));\n assert!(\n !fs::try_exists(&override_path).await.unwrap(),\n \"refused write must not touch the override file\"\n );\n assert!(\n !fs::read_to_string(config.config_path())\n .await\n .unwrap_or_default()\n .contains(\"MY_TOKEN\"),\n \"refused write must not touch config.toml either\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_set_writes_into_override_file_when_forced() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"old\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n handle_set(&config, \"MY_TOKEN\", \"newval\", true)\n .await\n .unwrap();\n\n let content = fs::read_to_string(&override_path).await.unwrap();\n assert!(content.contains(\"MY_TOKEN = \\\"newval\\\"\"));\n assert!(\n !fs::read_to_string(config.config_path())\n .await\n .unwrap_or_default()\n .contains(\"MY_TOKEN\"),\n \"forced write must go into the override file, not config.toml\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_refuses_when_shadowed_without_force() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"secret\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n let err = handle_delete(&config, \"MY_TOKEN\", false).await.unwrap_err();\n\n assert!(err.to_string().contains(override_path.to_str().unwrap()));\n let content = fs::read_to_string(&override_path).await.unwrap();\n assert!(\n content.contains(\"MY_TOKEN\"),\n \"refused delete must leave the override file untouched\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_removes_from_override_file_when_forced() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n let override_path = dir.join(\"shine.env.toml\");\n fs::write(&override_path, \"MY_TOKEN = \\\"secret\\\"\\nOTHER = \\\"kept\\\"\\n\")\n .await\n .unwrap();\n shadow_key(&mut config, \"MY_TOKEN\", override_path.clone(), false);\n\n handle_delete(&config, \"MY_TOKEN\", true).await.unwrap();\n\n let content = fs::read_to_string(&override_path).await.unwrap();\n let table: toml::Table = toml::from_str(&content).unwrap();\n assert!(!table.contains_key(\"MY_TOKEN\"));\n assert!(table.contains_key(\"OTHER\"));\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n}\n"}, "02e49ceea9d240f0af263f4fdb938f561ce6125b": {"t": 1785750192.0828683, "n": 24276, "h": "901a6097027cddea", "c": "//! Handlers for `shine env show/set/delete/get/decrypt/export/encrypt`.\n\nuse anyhow::{Context, Result, bail};\n\nuse super::{EnvConfig, StoredValue, resolve_stored_value, secret_key};\nuse crate::config::Config;\nuse crate::secret::{BackendKind, EncryptRecipients};\nuse crate::{colors, path_display, secret, shells};\n\npub async fn handle_show(config: &Config, reveal: bool) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let catalog = super::catalog::load(config).await?;\n let terminal_width = usize::from(console::Term::stdout().size().1).max(40);\n let key_width = env\n .iter()\n .map(|(key, _)| key.chars().count())\n .max()\n .unwrap_or(0);\n\n println!(\"{}\", colors::bold(\"Environment\"));\n println!();\n if env.as_map().is_empty() {\n println!(\" {}\", colors::dim(\"No variables configured.\"));\n }\n for (k, v) in env.iter() {\n let metadata = catalog.get(k);\n let description = env\n .description(k)\n .or_else(|| metadata.map(|item| item.description.as_str()))\n .unwrap_or_default();\n let sensitive = metadata.is_some_and(|item| item.sensitive) || is_sensitive_env_key(k);\n let display_value = display_env_value(v, sensitive, reveal);\n let (display_value, description) =\n fit_env_row(&display_value, description, key_width, terminal_width);\n let key_padding = \" \".repeat(key_width.saturating_sub(k.chars().count()));\n if description.is_empty() {\n println!(\" {}{} {}\", colors::cyan(k), key_padding, display_value);\n } else {\n println!(\n \" {}{} {:<value_width$} {}\",\n colors::cyan(k),\n key_padding,\n display_value,\n colors::dim(&description),\n value_width = env_value_width(key_width, terminal_width),\n );\n }\n }\n println!();\n println!(\n \" {} {}\",\n colors::dim(\"Config\"),\n colors::dim(&path_display::format(config.config_path()))\n );\n println!(\n \" {}\",\n colors::dim(&format!(\"{} variables\", env.as_map().len()))\n );\n Ok(())\n}\n\nfn is_sensitive_env_key(key: &str) -> bool {\n let key = key.to_ascii_uppercase();\n [\n \"SECRET\",\n \"TOKEN\",\n \"PASSWORD\",\n \"PASSPHRASE\",\n \"API_KEY\",\n \"PRIVATE_KEY\",\n \"ACCESS_KEY\",\n ]\n .iter()\n .any(|suffix| key == *suffix || key.ends_with(&format!(\"_{suffix}\")))\n}\n\nfn display_env_value(value: &str, sensitive: bool, reveal: bool) -> String {\n if value.is_empty() {\n \"<empty>\".to_string()\n } else if sensitive && !reveal {\n \"<redacted>\".to_string()\n } else {\n value.to_string()\n }\n}\n\nfn env_value_width(key_width: usize, terminal_width: usize) -> usize {\n terminal_width.saturating_sub(key_width + 28).clamp(12, 36)\n}\n\nfn fit_env_row(\n value: &str,\n description: &str,\n key_width: usize,\n terminal_width: usize,\n) -> (String, String) {\n let value_width = env_value_width(key_width, terminal_width);\n let value = truncate_text(value, value_width);\n let description_width = terminal_width.saturating_sub(2 + key_width + 2 + value_width + 2);\n let description = if description_width < 8 {\n String::new()\n } else {\n truncate_text(description, description_width)\n };\n (value, description)\n}\n\nfn truncate_text(value: &str, max_width: usize) -> String {\n if value.chars().count() <= max_width {\n return value.to_string();\n }\n if max_width <= 1 {\n return \"…\".to_string();\n }\n let mut result = value.chars().take(max_width - 1).collect::<String>();\n result.push('…');\n result\n}\n\npub async fn handle_set(config: &Config, key: &str, value: &str) -> Result<()> {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(key, value);\n env.save(config).await?;\n println!(\"{}\", colors::green(&format!(\"set {key} = \\\"{value}\\\"\")));\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_delete(config: &Config, key: &str) -> Result<()> {\n let mut env = EnvConfig::load_or_init(config).await?;\n if env.remove(key).is_none() {\n bail!(\"{key} is not set in the active config [env]\");\n }\n env.save(config).await?;\n println!(\"{}\", colors::green(&format!(\"deleted {key}\")));\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_get(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n match env.get(key) {\n Some(v) => println!(\"{v}\"),\n None => {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\"{key} is not set in the active config [env]\"))\n );\n std::process::exit(1);\n }\n }\n Ok(())\n}\n\npub async fn handle_decrypt(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n let plaintext = secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {key}\"))?;\n print!(\"{plaintext}\");\n Ok(())\n}\n\npub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {\n validate_env_export_key(key)?;\n if let Some(alias) = alias {\n validate_env_export_key(alias)?;\n }\n let env = EnvConfig::load_or_init(config).await?;\n let value = match resolve_env_export_value(&env, key)? {\n EnvExportValue::Secret {\n key: secret_key,\n value,\n } => secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {secret_key}\"))?,\n EnvExportValue::Plaintext(value) => value.to_string(),\n };\n let export_as = alias.unwrap_or(key);\n println!(\n \"{}\",\n format_env_export(&config.shell_type, export_as, &value)\n );\n Ok(())\n}\n\ntype EnvExportValue<'a> = StoredValue<'a>;\n\nfn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {\n resolve_stored_value(env, key)\n}\n\nfn env_export_secret_key(key: &str) -> String {\n secret_key(key)\n}\n\nfn validate_env_export_key(key: &str) -> Result<()> {\n let mut chars = key.chars();\n let Some(first) = chars.next() else {\n bail!(\"env export key must not be empty\");\n };\n if !(first == '_' || first.is_ascii_alphabetic()) {\n bail!(\"env export key must start with a letter or underscore: {key}\");\n }\n if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {\n bail!(\"env export key must contain only letters, digits, and underscores: {key}\");\n }\n Ok(())\n}\n\n/// `pub(crate)` so `theme::handle_sync` can reuse the same per-shell quoting\n/// instead of adding a fourth `single_quote` implementation to the codebase\n/// (see docs/terminal-theme-sync-prd.md §7/§10).\npub(crate) fn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {\n match shell {\n shells::ShellType::Fish => format!(\"set -gx {key} {}\", fish_quote(value)),\n shells::ShellType::PowerShell => {\n format!(\"$env:{key} = {}\", powershell_string_quote(value))\n }\n _ => format!(\"export {key}={}\", posix_shell_quote(value)),\n }\n}\n\nfn posix_shell_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"'\\\\''\"))\n}\n\nfn fish_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\\\', \"\\\\\\\\\").replace('\\'', \"\\\\'\"))\n}\n\nfn powershell_string_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"''\"))\n}\n\n#[derive(Debug, PartialEq, Eq)]\nenum EnvEncryptOutput {\n Print,\n Set(String),\n}\n\nfn resolve_env_encrypt_output(\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<EnvEncryptOutput> {\n if let Some(key) = set_key {\n return Ok(EnvEncryptOutput::Set(key.to_string()));\n }\n if let Some(key) = from_key {\n validate_env_export_key(key)?;\n return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));\n }\n Ok(EnvEncryptOutput::Print)\n}\n\nfn resolve_encrypt_backend(config: &Config, backend: Option<&str>) -> Result<BackendKind> {\n if let Some(backend) = backend.map(str::trim).filter(|value| !value.is_empty()) {\n return backend.parse();\n }\n if let Some(backend) = config\n .secret_backend\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n {\n return backend.parse();\n }\n Ok(BackendKind::default())\n}\n\nfn clean_recipients(recipients: &[String]) -> Vec<String> {\n recipients\n .iter()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .collect()\n}\n\nfn resolve_encrypt_recipients(\n backend: BackendKind,\n cli_recipients: &[String],\n config: &Config,\n) -> Result<EncryptRecipients> {\n let cli_recipients = clean_recipients(cli_recipients);\n if !cli_recipients.is_empty() {\n if backend == BackendKind::Gpg\n && let Some(hint) = cli_recipients\n .iter()\n .find(|value| value.starts_with(\"age1\"))\n {\n bail!(\"recipient \\\"{hint}\\\" looks like an age recipient; did you mean --backend age?\");\n }\n return Ok(match backend {\n BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),\n BackendKind::Age => EncryptRecipients::Age(cli_recipients),\n });\n }\n\n match backend {\n BackendKind::Gpg => {\n let recipient = config\n .gpg_key_id\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .context(\n \"GPG recipient is required; pass -r/--recipient, set gpg_key_id, or set secret_backend/age_recipients for age\",\n )?;\n Ok(EncryptRecipients::Gpg(vec![recipient.to_string()]))\n }\n BackendKind::Age => {\n let recipients = clean_recipients(&config.age_recipients);\n if recipients.is_empty() {\n bail!(\n \"age recipients are required; pass -r/--recipient or set age_recipients in config.toml\"\n );\n }\n Ok(EncryptRecipients::Age(recipients))\n }\n }\n}\n\npub async fn handle_encrypt(\n config: &Config,\n backend: Option<&str>,\n recipients: &[String],\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<()> {\n use std::io::Read as _;\n\n let backend = resolve_encrypt_backend(config, backend)?;\n let recipients = resolve_encrypt_recipients(backend, recipients, config)?;\n let plaintext = if let Some(key) = from_key {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n value.as_bytes().to_vec()\n } else {\n let mut input = Vec::new();\n std::io::stdin()\n .read_to_end(&mut input)\n .context(\"reading secret from stdin\")?;\n input\n };\n let encoded = secret::encrypt_secret(&plaintext, &recipients)\n .await\n .context(\"encrypting secret\")?;\n match resolve_env_encrypt_output(set_key, from_key)? {\n EnvEncryptOutput::Set(key) => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(&key, &encoded);\n env.save(config).await?;\n println!(\"{}\", colors::green(&format!(\"set {key} = \\\"{encoded}\\\"\")));\n }\n EnvEncryptOutput::Print => println!(\"{encoded}\"),\n }\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::config::Config;\n use tokio::fs;\n\n async fn make_temp_dir() -> std::path::PathBuf {\n crate::test_support::make_temp_dir(\"shine-env-cmd-test\").await\n }\n\n fn config_in(dir: &std::path::Path) -> Config {\n crate::test_support::test_config(dir)\n }\n\n #[test]\n fn env_show_redacts_sensitive_values() {\n assert_eq!(display_env_value(\"secret\", true, false), \"<redacted>\");\n assert_eq!(display_env_value(\"secret\", true, true), \"secret\");\n assert_eq!(display_env_value(\"\", true, false), \"<empty>\");\n assert!(is_sensitive_env_key(\"MY_API_KEY\"));\n assert!(is_sensitive_env_key(\"token\"));\n assert!(!is_sensitive_env_key(\"MONKEY\"));\n }\n\n #[test]\n fn env_show_truncates_long_values_to_requested_width() {\n assert_eq!(truncate_text(\"abcdefgh\", 5), \"abcd…\");\n let (value, description) = fit_env_row(\n \"abcdefghijklmnopqrstuvwxyz\",\n \"A description that is also fairly long\",\n 8,\n 48,\n );\n assert!(value.chars().count() <= env_value_width(8, 48));\n assert!(description.chars().count() <= 48);\n }\n\n #[test]\n fn env_export_uses_alias_as_variable_name() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"MY_ALIAS\", value),\n \"export MY_ALIAS='secret123'\"\n );\n }\n\n #[test]\n fn env_export_alias_formats_powershell_correctly() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"MY_ALIAS\", value),\n \"$env:MY_ALIAS = 'secret123'\"\n );\n }\n\n #[tokio::test]\n async fn env_delete_removes_key_from_saved_config() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n config.env.insert(\"MY_TOKEN\".into(), \"secret\".into());\n config.save().await.unwrap();\n\n handle_delete(&config, \"MY_TOKEN\").await.unwrap();\n\n let contents = fs::read_to_string(config.config_path()).await.unwrap();\n let parsed: toml::Table = toml::from_str(&contents).unwrap();\n let env = parsed\n .get(\"env\")\n .and_then(|value| value.as_table())\n .unwrap();\n assert!(\n !env.contains_key(\"MY_TOKEN\"),\n \"deleted key should not remain in saved config: {contents}\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_fails_when_key_is_missing() {\n let dir = make_temp_dir().await;\n let config = config_in(&dir);\n\n let err = handle_delete(&config, \"MY_TOKEN\").await.unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN is not set in the active config [env]\"),\n \"error should explain missing key: {err:#}\"\n );\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[test]\n fn env_export_secret_key_appends_secret_suffix() {\n assert_eq!(\n env_export_secret_key(\"DEEPSEEK_API_KEY\"),\n \"DEEPSEEK_API_KEY_SECRET\"\n );\n assert_eq!(env_export_secret_key(\"xxx\"), \"xxx_SECRET\");\n }\n\n #[test]\n fn env_export_resolves_secret_when_present() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_falls_back_to_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Plaintext(\"plain\")\n );\n }\n\n #[test]\n fn env_export_secret_wins_over_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_reports_both_missing_keys() {\n let env = EnvConfig::default();\n\n let err = resolve_env_export_value(&env, \"MY_TOKEN\").unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]\"),\n \"error should explain both checked keys: {err:#}\"\n );\n }\n\n #[test]\n fn env_export_key_validation_accepts_shell_variable_names() {\n for key in [\"FOO\", \"_FOO\", \"foo_123\", \"A1\"] {\n validate_env_export_key(key).unwrap();\n }\n }\n\n #[test]\n fn env_export_key_validation_rejects_unsafe_names() {\n for key in [\"\", \"1FOO\", \"FOO-BAR\", \"FOO;BAR\", \"FOO BAR\", \"FOO.SECRET\"] {\n assert!(\n validate_env_export_key(key).is_err(),\n \"key should be rejected: {key}\"\n );\n }\n }\n\n #[test]\n fn env_export_formats_posix_shell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"TOKEN\", value),\n \"export TOKEN='abc def'\\\\''ghi$HOME\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_fish_shell_code_safely() {\n let value = \"abc def'ghi\\\\path\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Fish, \"TOKEN\", value),\n \"set -gx TOKEN 'abc def\\\\'ghi\\\\\\\\path\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_powershell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; Remove-Item /\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"TOKEN\", value),\n \"$env:TOKEN = 'abc def''ghi$HOME\\nnext; Remove-Item /'\"\n );\n }\n\n #[test]\n fn env_encrypt_output_defaults_from_key_to_secret_key() {\n assert_eq!(\n resolve_env_encrypt_output(None, Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"GH_TOKEN_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_explicit_set_wins_over_default() {\n assert_eq!(\n resolve_env_encrypt_output(Some(\"CUSTOM_SECRET\"), Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"CUSTOM_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_prints_stdin_without_set() {\n assert_eq!(\n resolve_env_encrypt_output(None, None).unwrap(),\n EnvEncryptOutput::Print\n );\n }\n\n #[test]\n fn env_encrypt_output_rejects_invalid_inferred_from_key() {\n let err = resolve_env_encrypt_output(None, Some(\"GH-TOKEN\")).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"env export key must contain only letters, digits, and underscores\"),\n \"error should explain invalid inferred key: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_backend_cli_wins_over_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, Some(\"gpg\")).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_backend_falls_back_to_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Age\n );\n }\n\n #[test]\n fn encrypt_backend_defaults_to_gpg() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_recipients_cli_wins_over_config_for_gpg() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"cli@example.com\".to_string()], &config)\n .unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"cli@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"config@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_treats_empty_config_as_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\" \".to_string());\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_gpg_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_age_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.age_recipients = vec![\"age1qexample\".to_string()];\n\n let recipients = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Age(values) => assert_eq!(values, vec![\"age1qexample\"]),\n EncryptRecipients::Gpg(_) => panic!(\"expected age recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_age_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap_err();\n\n assert!(\n err.to_string().contains(\"age recipients are required\"),\n \"error should explain how to set age recipients: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_hints_when_age_recipient_used_with_gpg_backend() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"age1qexample\".to_string()], &config)\n .unwrap_err();\n\n assert!(\n err.to_string().contains(\"did you mean --backend age\"),\n \"error should hint at the age backend: {err:#}\"\n );\n }\n}\n"}, "db26dc7c74b925eadba5aaa345405289927810a3": {"t": 1785750193.3187616, "n": 24060, "h": "20a4c91452482173", "c": "//! Handlers for `shine env show/set/delete/get/decrypt/export/encrypt`.\n\nuse anyhow::{Context, Result, bail};\n\nuse super::{EnvConfig, StoredValue, resolve_stored_value, secret_key};\nuse crate::config::Config;\nuse crate::secret::{BackendKind, EncryptRecipients};\nuse crate::{colors, path_display, secret, shells};\n\npub async fn handle_show(config: &Config, reveal: bool) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let catalog = super::catalog::load(config).await?;\n let terminal_width = usize::from(console::Term::stdout().size().1).max(40);\n let key_width = env\n .iter()\n .map(|(key, _)| key.chars().count())\n .max()\n .unwrap_or(0);\n\n println!(\"{}\", colors::bold(\"Environment\"));\n println!();\n if env.as_map().is_empty() {\n println!(\" {}\", colors::dim(\"No variables configured.\"));\n }\n for (k, v) in env.iter() {\n let metadata = catalog.get(k);\n let description = env\n .description(k)\n .or_else(|| metadata.map(|item| item.description.as_str()))\n .unwrap_or_default();\n let sensitive = metadata.is_some_and(|item| item.sensitive) || is_sensitive_env_key(k);\n let display_value = display_env_value(v, sensitive, reveal);\n let (display_value, description) =\n fit_env_row(&display_value, description, key_width, terminal_width);\n let key_padding = \" \".repeat(key_width.saturating_sub(k.chars().count()));\n if description.is_empty() {\n println!(\" {}{} {}\", colors::cyan(k), key_padding, display_value);\n } else {\n println!(\n \" {}{} {:<value_width$} {}\",\n colors::cyan(k),\n key_padding,\n display_value,\n colors::dim(&description),\n value_width = env_value_width(key_width, terminal_width),\n );\n }\n }\n println!();\n println!(\n \" {} {}\",\n colors::dim(\"Config\"),\n colors::dim(&path_display::format(config.config_path()))\n );\n println!(\n \" {}\",\n colors::dim(&format!(\"{} variables\", env.as_map().len()))\n );\n Ok(())\n}\n\nfn is_sensitive_env_key(key: &str) -> bool {\n let key = key.to_ascii_uppercase();\n [\n \"SECRET\",\n \"TOKEN\",\n \"PASSWORD\",\n \"PASSPHRASE\",\n \"API_KEY\",\n \"PRIVATE_KEY\",\n \"ACCESS_KEY\",\n ]\n .iter()\n .any(|suffix| key == *suffix || key.ends_with(&format!(\"_{suffix}\")))\n}\n\nfn display_env_value(value: &str, sensitive: bool, reveal: bool) -> String {\n if value.is_empty() {\n \"<empty>\".to_string()\n } else if sensitive && !reveal {\n \"<redacted>\".to_string()\n } else {\n value.to_string()\n }\n}\n\nfn env_value_width(key_width: usize, terminal_width: usize) -> usize {\n terminal_width.saturating_sub(key_width + 28).clamp(12, 36)\n}\n\nfn fit_env_row(\n value: &str,\n description: &str,\n key_width: usize,\n terminal_width: usize,\n) -> (String, String) {\n let value_width = env_value_width(key_width, terminal_width);\n let value = truncate_text(value, value_width);\n let description_width = terminal_width.saturating_sub(2 + key_width + 2 + value_width + 2);\n let description = if description_width < 8 {\n String::new()\n } else {\n truncate_text(description, description_width)\n };\n (value, description)\n}\n\nfn truncate_text(value: &str, max_width: usize) -> String {\n if value.chars().count() <= max_width {\n return value.to_string();\n }\n if max_width <= 1 {\n return \"…\".to_string();\n }\n let mut result = value.chars().take(max_width - 1).collect::<String>();\n result.push('…');\n result\n}\n\npub async fn handle_set(config: &Config, key: &str, value: &str) -> Result<()> {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(key, value);\n env.save(config).await?;\n println!(\"{}\", colors::green(&format!(\"set {key} = \\\"{value}\\\"\")));\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_delete(config: &Config, key: &str) -> Result<()> {\n let mut env = EnvConfig::load_or_init(config).await?;\n if env.remove(key).is_none() {\n bail!(\"{key} is not set in the active config [env]\");\n }\n env.save(config).await?;\n println!(\"{}\", colors::green(&format!(\"deleted {key}\")));\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_get(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n match env.get(key) {\n Some(v) => println!(\"{v}\"),\n None => {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\"{key} is not set in the active config [env]\"))\n );\n std::process::exit(1);\n }\n }\n Ok(())\n}\n\npub async fn handle_decrypt(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n let plaintext = secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {key}\"))?;\n print!(\"{plaintext}\");\n Ok(())\n}\n\npub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {\n validate_env_export_key(key)?;\n if let Some(alias) = alias {\n validate_env_export_key(alias)?;\n }\n let env = EnvConfig::load_or_init(config).await?;\n let value = match resolve_env_export_value(&env, key)? {\n EnvExportValue::Secret {\n key: secret_key,\n value,\n } => secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {secret_key}\"))?,\n EnvExportValue::Plaintext(value) => value.to_string(),\n };\n let export_as = alias.unwrap_or(key);\n println!(\n \"{}\",\n format_env_export(&config.shell_type, export_as, &value)\n );\n Ok(())\n}\n\ntype EnvExportValue<'a> = StoredValue<'a>;\n\nfn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {\n resolve_stored_value(env, key)\n}\n\nfn env_export_secret_key(key: &str) -> String {\n secret_key(key)\n}\n\nfn validate_env_export_key(key: &str) -> Result<()> {\n let mut chars = key.chars();\n let Some(first) = chars.next() else {\n bail!(\"env export key must not be empty\");\n };\n if !(first == '_' || first.is_ascii_alphabetic()) {\n bail!(\"env export key must start with a letter or underscore: {key}\");\n }\n if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {\n bail!(\"env export key must contain only letters, digits, and underscores: {key}\");\n }\n Ok(())\n}\n\nfn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {\n match shell {\n shells::ShellType::Fish => format!(\"set -gx {key} {}\", fish_quote(value)),\n shells::ShellType::PowerShell => {\n format!(\"$env:{key} = {}\", powershell_string_quote(value))\n }\n _ => format!(\"export {key}={}\", posix_shell_quote(value)),\n }\n}\n\nfn posix_shell_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"'\\\\''\"))\n}\n\nfn fish_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\\\', \"\\\\\\\\\").replace('\\'', \"\\\\'\"))\n}\n\nfn powershell_string_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"''\"))\n}\n\n#[derive(Debug, PartialEq, Eq)]\nenum EnvEncryptOutput {\n Print,\n Set(String),\n}\n\nfn resolve_env_encrypt_output(\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<EnvEncryptOutput> {\n if let Some(key) = set_key {\n return Ok(EnvEncryptOutput::Set(key.to_string()));\n }\n if let Some(key) = from_key {\n validate_env_export_key(key)?;\n return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));\n }\n Ok(EnvEncryptOutput::Print)\n}\n\nfn resolve_encrypt_backend(config: &Config, backend: Option<&str>) -> Result<BackendKind> {\n if let Some(backend) = backend.map(str::trim).filter(|value| !value.is_empty()) {\n return backend.parse();\n }\n if let Some(backend) = config\n .secret_backend\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n {\n return backend.parse();\n }\n Ok(BackendKind::default())\n}\n\nfn clean_recipients(recipients: &[String]) -> Vec<String> {\n recipients\n .iter()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .collect()\n}\n\nfn resolve_encrypt_recipients(\n backend: BackendKind,\n cli_recipients: &[String],\n config: &Config,\n) -> Result<EncryptRecipients> {\n let cli_recipients = clean_recipients(cli_recipients);\n if !cli_recipients.is_empty() {\n if backend == BackendKind::Gpg\n && let Some(hint) = cli_recipients\n .iter()\n .find(|value| value.starts_with(\"age1\"))\n {\n bail!(\"recipient \\\"{hint}\\\" looks like an age recipient; did you mean --backend age?\");\n }\n return Ok(match backend {\n BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),\n BackendKind::Age => EncryptRecipients::Age(cli_recipients),\n });\n }\n\n match backend {\n BackendKind::Gpg => {\n let recipient = config\n .gpg_key_id\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .context(\n \"GPG recipient is required; pass -r/--recipient, set gpg_key_id, or set secret_backend/age_recipients for age\",\n )?;\n Ok(EncryptRecipients::Gpg(vec![recipient.to_string()]))\n }\n BackendKind::Age => {\n let recipients = clean_recipients(&config.age_recipients);\n if recipients.is_empty() {\n bail!(\n \"age recipients are required; pass -r/--recipient or set age_recipients in config.toml\"\n );\n }\n Ok(EncryptRecipients::Age(recipients))\n }\n }\n}\n\npub async fn handle_encrypt(\n config: &Config,\n backend: Option<&str>,\n recipients: &[String],\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<()> {\n use std::io::Read as _;\n\n let backend = resolve_encrypt_backend(config, backend)?;\n let recipients = resolve_encrypt_recipients(backend, recipients, config)?;\n let plaintext = if let Some(key) = from_key {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n value.as_bytes().to_vec()\n } else {\n let mut input = Vec::new();\n std::io::stdin()\n .read_to_end(&mut input)\n .context(\"reading secret from stdin\")?;\n input\n };\n let encoded = secret::encrypt_secret(&plaintext, &recipients)\n .await\n .context(\"encrypting secret\")?;\n match resolve_env_encrypt_output(set_key, from_key)? {\n EnvEncryptOutput::Set(key) => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(&key, &encoded);\n env.save(config).await?;\n println!(\"{}\", colors::green(&format!(\"set {key} = \\\"{encoded}\\\"\")));\n }\n EnvEncryptOutput::Print => println!(\"{encoded}\"),\n }\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::config::Config;\n use tokio::fs;\n\n async fn make_temp_dir() -> std::path::PathBuf {\n crate::test_support::make_temp_dir(\"shine-env-cmd-test\").await\n }\n\n fn config_in(dir: &std::path::Path) -> Config {\n crate::test_support::test_config(dir)\n }\n\n #[test]\n fn env_show_redacts_sensitive_values() {\n assert_eq!(display_env_value(\"secret\", true, false), \"<redacted>\");\n assert_eq!(display_env_value(\"secret\", true, true), \"secret\");\n assert_eq!(display_env_value(\"\", true, false), \"<empty>\");\n assert!(is_sensitive_env_key(\"MY_API_KEY\"));\n assert!(is_sensitive_env_key(\"token\"));\n assert!(!is_sensitive_env_key(\"MONKEY\"));\n }\n\n #[test]\n fn env_show_truncates_long_values_to_requested_width() {\n assert_eq!(truncate_text(\"abcdefgh\", 5), \"abcd…\");\n let (value, description) = fit_env_row(\n \"abcdefghijklmnopqrstuvwxyz\",\n \"A description that is also fairly long\",\n 8,\n 48,\n );\n assert!(value.chars().count() <= env_value_width(8, 48));\n assert!(description.chars().count() <= 48);\n }\n\n #[test]\n fn env_export_uses_alias_as_variable_name() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"MY_ALIAS\", value),\n \"export MY_ALIAS='secret123'\"\n );\n }\n\n #[test]\n fn env_export_alias_formats_powershell_correctly() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"MY_ALIAS\", value),\n \"$env:MY_ALIAS = 'secret123'\"\n );\n }\n\n #[tokio::test]\n async fn env_delete_removes_key_from_saved_config() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n config.env.insert(\"MY_TOKEN\".into(), \"secret\".into());\n config.save().await.unwrap();\n\n handle_delete(&config, \"MY_TOKEN\").await.unwrap();\n\n let contents = fs::read_to_string(config.config_path()).await.unwrap();\n let parsed: toml::Table = toml::from_str(&contents).unwrap();\n let env = parsed\n .get(\"env\")\n .and_then(|value| value.as_table())\n .unwrap();\n assert!(\n !env.contains_key(\"MY_TOKEN\"),\n \"deleted key should not remain in saved config: {contents}\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_fails_when_key_is_missing() {\n let dir = make_temp_dir().await;\n let config = config_in(&dir);\n\n let err = handle_delete(&config, \"MY_TOKEN\").await.unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN is not set in the active config [env]\"),\n \"error should explain missing key: {err:#}\"\n );\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[test]\n fn env_export_secret_key_appends_secret_suffix() {\n assert_eq!(\n env_export_secret_key(\"DEEPSEEK_API_KEY\"),\n \"DEEPSEEK_API_KEY_SECRET\"\n );\n assert_eq!(env_export_secret_key(\"xxx\"), \"xxx_SECRET\");\n }\n\n #[test]\n fn env_export_resolves_secret_when_present() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_falls_back_to_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Plaintext(\"plain\")\n );\n }\n\n #[test]\n fn env_export_secret_wins_over_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_reports_both_missing_keys() {\n let env = EnvConfig::default();\n\n let err = resolve_env_export_value(&env, \"MY_TOKEN\").unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]\"),\n \"error should explain both checked keys: {err:#}\"\n );\n }\n\n #[test]\n fn env_export_key_validation_accepts_shell_variable_names() {\n for key in [\"FOO\", \"_FOO\", \"foo_123\", \"A1\"] {\n validate_env_export_key(key).unwrap();\n }\n }\n\n #[test]\n fn env_export_key_validation_rejects_unsafe_names() {\n for key in [\"\", \"1FOO\", \"FOO-BAR\", \"FOO;BAR\", \"FOO BAR\", \"FOO.SECRET\"] {\n assert!(\n validate_env_export_key(key).is_err(),\n \"key should be rejected: {key}\"\n );\n }\n }\n\n #[test]\n fn env_export_formats_posix_shell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"TOKEN\", value),\n \"export TOKEN='abc def'\\\\''ghi$HOME\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_fish_shell_code_safely() {\n let value = \"abc def'ghi\\\\path\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Fish, \"TOKEN\", value),\n \"set -gx TOKEN 'abc def\\\\'ghi\\\\\\\\path\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_powershell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; Remove-Item /\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"TOKEN\", value),\n \"$env:TOKEN = 'abc def''ghi$HOME\\nnext; Remove-Item /'\"\n );\n }\n\n #[test]\n fn env_encrypt_output_defaults_from_key_to_secret_key() {\n assert_eq!(\n resolve_env_encrypt_output(None, Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"GH_TOKEN_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_explicit_set_wins_over_default() {\n assert_eq!(\n resolve_env_encrypt_output(Some(\"CUSTOM_SECRET\"), Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"CUSTOM_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_prints_stdin_without_set() {\n assert_eq!(\n resolve_env_encrypt_output(None, None).unwrap(),\n EnvEncryptOutput::Print\n );\n }\n\n #[test]\n fn env_encrypt_output_rejects_invalid_inferred_from_key() {\n let err = resolve_env_encrypt_output(None, Some(\"GH-TOKEN\")).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"env export key must contain only letters, digits, and underscores\"),\n \"error should explain invalid inferred key: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_backend_cli_wins_over_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, Some(\"gpg\")).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_backend_falls_back_to_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Age\n );\n }\n\n #[test]\n fn encrypt_backend_defaults_to_gpg() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_recipients_cli_wins_over_config_for_gpg() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"cli@example.com\".to_string()], &config)\n .unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"cli@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"config@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_treats_empty_config_as_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\" \".to_string());\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_gpg_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_age_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.age_recipients = vec![\"age1qexample\".to_string()];\n\n let recipients = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Age(values) => assert_eq!(values, vec![\"age1qexample\"]),\n EncryptRecipients::Gpg(_) => panic!(\"expected age recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_age_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap_err();\n\n assert!(\n err.to_string().contains(\"age recipients are required\"),\n \"error should explain how to set age recipients: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_hints_when_age_recipient_used_with_gpg_backend() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"age1qexample\".to_string()], &config)\n .unwrap_err();\n\n assert!(\n err.to_string().contains(\"did you mean --backend age\"),\n \"error should hint at the age backend: {err:#}\"\n );\n }\n}\n"}, "c0acd78e82d01da6a344e2c3bfb036e25de836fb": {"t": 1785750194.529461, "n": 24139, "h": "be5e8d1fa3d45a83", "c": "//! Handlers for `shine env show/set/delete/get/decrypt/export/encrypt`.\n\nuse anyhow::{Context, Result, bail};\n\nuse super::{EnvConfig, StoredValue, resolve_stored_value, secret_key};\nuse crate::config::Config;\nuse crate::secret::{BackendKind, EncryptRecipients};\nuse crate::{colors, path_display, secret, shells};\n\npub async fn handle_show(config: &Config, reveal: bool) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let catalog = super::catalog::load(config).await?;\n let terminal_width = usize::from(console::Term::stdout().size().1).max(40);\n let key_width = env\n .iter()\n .map(|(key, _)| key.chars().count())\n .max()\n .unwrap_or(0);\n\n println!(\"{}\", colors::bold(\"Environment\"));\n println!();\n if env.as_map().is_empty() {\n println!(\" {}\", colors::dim(\"No variables configured.\"));\n }\n for (k, v) in env.iter() {\n let metadata = catalog.get(k);\n let description = env\n .description(k)\n .or_else(|| metadata.map(|item| item.description.as_str()))\n .unwrap_or_default();\n let sensitive = metadata.is_some_and(|item| item.sensitive) || is_sensitive_env_key(k);\n let display_value = display_env_value(v, sensitive, reveal);\n let (display_value, description) =\n fit_env_row(&display_value, description, key_width, terminal_width);\n let key_padding = \" \".repeat(key_width.saturating_sub(k.chars().count()));\n if description.is_empty() {\n println!(\" {}{} {}\", colors::cyan(k), key_padding, display_value);\n } else {\n println!(\n \" {}{} {:<value_width$} {}\",\n colors::cyan(k),\n key_padding,\n display_value,\n colors::dim(&description),\n value_width = env_value_width(key_width, terminal_width),\n );\n }\n }\n println!();\n println!(\n \" {} {}\",\n colors::dim(\"Config\"),\n colors::dim(&path_display::format(config.config_path()))\n );\n println!(\n \" {}\",\n colors::dim(&format!(\"{} variables\", env.as_map().len()))\n );\n Ok(())\n}\n\nfn is_sensitive_env_key(key: &str) -> bool {\n let key = key.to_ascii_uppercase();\n [\n \"SECRET\",\n \"TOKEN\",\n \"PASSWORD\",\n \"PASSPHRASE\",\n \"API_KEY\",\n \"PRIVATE_KEY\",\n \"ACCESS_KEY\",\n ]\n .iter()\n .any(|suffix| key == *suffix || key.ends_with(&format!(\"_{suffix}\")))\n}\n\nfn display_env_value(value: &str, sensitive: bool, reveal: bool) -> String {\n if value.is_empty() {\n \"<empty>\".to_string()\n } else if sensitive && !reveal {\n \"<redacted>\".to_string()\n } else {\n value.to_string()\n }\n}\n\nfn env_value_width(key_width: usize, terminal_width: usize) -> usize {\n terminal_width.saturating_sub(key_width + 28).clamp(12, 36)\n}\n\nfn fit_env_row(\n value: &str,\n description: &str,\n key_width: usize,\n terminal_width: usize,\n) -> (String, String) {\n let value_width = env_value_width(key_width, terminal_width);\n let value = truncate_text(value, value_width);\n let description_width = terminal_width.saturating_sub(2 + key_width + 2 + value_width + 2);\n let description = if description_width < 8 {\n String::new()\n } else {\n truncate_text(description, description_width)\n };\n (value, description)\n}\n\nfn truncate_text(value: &str, max_width: usize) -> String {\n if value.chars().count() <= max_width {\n return value.to_string();\n }\n if max_width <= 1 {\n return \"…\".to_string();\n }\n let mut result = value.chars().take(max_width - 1).collect::<String>();\n result.push('…');\n result\n}\n\npub async fn handle_set(config: &Config, key: &str, value: &str) -> Result<()> {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(key, value);\n env.save(config).await?;\n println!(\"{}\", colors::green(&format!(\"set {key} = \\\"{value}\\\"\")));\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_delete(config: &Config, key: &str) -> Result<()> {\n let mut env = EnvConfig::load_or_init(config).await?;\n if env.remove(key).is_none() {\n bail!(\"{key} is not set in the active config [env]\");\n }\n env.save(config).await?;\n println!(\"{}\", colors::green(&format!(\"deleted {key}\")));\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_get(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n match env.get(key) {\n Some(v) => println!(\"{v}\"),\n None => {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\"{key} is not set in the active config [env]\"))\n );\n std::process::exit(1);\n }\n }\n Ok(())\n}\n\npub async fn handle_decrypt(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n let plaintext = secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {key}\"))?;\n print!(\"{plaintext}\");\n Ok(())\n}\n\npub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {\n validate_env_export_key(key)?;\n if let Some(alias) = alias {\n validate_env_export_key(alias)?;\n }\n let env = EnvConfig::load_or_init(config).await?;\n let value = match resolve_env_export_value(&env, key)? {\n EnvExportValue::Secret {\n key: secret_key,\n value,\n } => secret::decrypt_secret(value, &config.age_identities())\n .await\n .with_context(|| format!(\"decrypting {secret_key}\"))?,\n EnvExportValue::Plaintext(value) => value.to_string(),\n };\n let export_as = alias.unwrap_or(key);\n println!(\n \"{}\",\n format_env_export(&config.shell_type, export_as, &value)\n );\n Ok(())\n}\n\ntype EnvExportValue<'a> = StoredValue<'a>;\n\nfn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {\n resolve_stored_value(env, key)\n}\n\nfn env_export_secret_key(key: &str) -> String {\n secret_key(key)\n}\n\nfn validate_env_export_key(key: &str) -> Result<()> {\n let mut chars = key.chars();\n let Some(first) = chars.next() else {\n bail!(\"env export key must not be empty\");\n };\n if !(first == '_' || first.is_ascii_alphabetic()) {\n bail!(\"env export key must start with a letter or underscore: {key}\");\n }\n if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {\n bail!(\"env export key must contain only letters, digits, and underscores: {key}\");\n }\n Ok(())\n}\n\nfn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {\n match shell {\n shells::ShellType::Fish => format!(\"set -gx {key} {}\", fish_quote(value)),\n shells::ShellType::PowerShell => {\n format!(\"$env:{key} = {}\", powershell_string_quote(value))\n }\n _ => format!(\"export {key}={}\", posix_shell_quote(value)),\n }\n}\n\nfn posix_shell_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"'\\\\''\"))\n}\n\nfn fish_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\\\', \"\\\\\\\\\").replace('\\'', \"\\\\'\"))\n}\n\nfn powershell_string_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"''\"))\n}\n\n#[derive(Debug, PartialEq, Eq)]\nenum EnvEncryptOutput {\n Print,\n Set(String),\n}\n\nfn resolve_env_encrypt_output(\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<EnvEncryptOutput> {\n if let Some(key) = set_key {\n return Ok(EnvEncryptOutput::Set(key.to_string()));\n }\n if let Some(key) = from_key {\n validate_env_export_key(key)?;\n return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));\n }\n Ok(EnvEncryptOutput::Print)\n}\n\nfn resolve_encrypt_backend(config: &Config, backend: Option<&str>) -> Result<BackendKind> {\n if let Some(backend) = backend.map(str::trim).filter(|value| !value.is_empty()) {\n return backend.parse();\n }\n if let Some(backend) = config\n .secret_backend\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n {\n return backend.parse();\n }\n Ok(BackendKind::default())\n}\n\nfn clean_recipients(recipients: &[String]) -> Vec<String> {\n recipients\n .iter()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .collect()\n}\n\nfn resolve_encrypt_recipients(\n backend: BackendKind,\n cli_recipients: &[String],\n config: &Config,\n) -> Result<EncryptRecipients> {\n let cli_recipients = clean_recipients(cli_recipients);\n if !cli_recipients.is_empty() {\n if backend == BackendKind::Gpg\n && let Some(hint) = cli_recipients\n .iter()\n .find(|value| value.starts_with(\"age1\"))\n {\n bail!(\"recipient \\\"{hint}\\\" looks like an age recipient; did you mean --backend age?\");\n }\n return Ok(match backend {\n BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),\n BackendKind::Age => EncryptRecipients::Age(cli_recipients),\n });\n }\n\n match backend {\n BackendKind::Gpg => {\n let recipient = config\n .gpg_key_id\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .context(\n \"GPG recipient is required; pass -r/--recipient, set gpg_key_id, or set secret_backend/age_recipients for age\",\n )?;\n Ok(EncryptRecipients::Gpg(vec![recipient.to_string()]))\n }\n BackendKind::Age => {\n let recipients = clean_recipients(&config.age_recipients);\n if recipients.is_empty() {\n bail!(\n \"age recipients are required; pass -r/--recipient or set age_recipients in config.toml\"\n );\n }\n Ok(EncryptRecipients::Age(recipients))\n }\n }\n}\n\npub async fn handle_encrypt(\n config: &Config,\n backend: Option<&str>,\n recipients: &[String],\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<()> {\n use std::io::Read as _;\n\n let backend = resolve_encrypt_backend(config, backend)?;\n let recipients = resolve_encrypt_recipients(backend, recipients, config)?;\n let plaintext = if let Some(key) = from_key {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n value.as_bytes().to_vec()\n } else {\n let mut input = Vec::new();\n std::io::stdin()\n .read_to_end(&mut input)\n .context(\"reading secret from stdin\")?;\n input\n };\n let encoded = secret::encrypt_secret(&plaintext, &recipients)\n .await\n .context(\"encrypting secret\")?;\n match resolve_env_encrypt_output(set_key, from_key)? {\n EnvEncryptOutput::Set(key) => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(&key, &encoded);\n env.save(config).await?;\n println!(\"{}\", colors::green(&format!(\"set {key} = \\\"{encoded}\\\"\")));\n }\n EnvEncryptOutput::Print => println!(\"{encoded}\"),\n }\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::config::Config;\n use tokio::fs;\n\n async fn make_temp_dir() -> std::path::PathBuf {\n let dir = std::env::temp_dir().join(format!(\"shine-env-cmd-test-{}\", uuid::Uuid::new_v4()));\n fs::create_dir_all(&dir).await.unwrap();\n dir\n }\n\n fn config_in(dir: &std::path::Path) -> Config {\n Config::new_for_test(dir)\n }\n\n #[test]\n fn env_show_redacts_sensitive_values() {\n assert_eq!(display_env_value(\"secret\", true, false), \"<redacted>\");\n assert_eq!(display_env_value(\"secret\", true, true), \"secret\");\n assert_eq!(display_env_value(\"\", true, false), \"<empty>\");\n assert!(is_sensitive_env_key(\"MY_API_KEY\"));\n assert!(is_sensitive_env_key(\"token\"));\n assert!(!is_sensitive_env_key(\"MONKEY\"));\n }\n\n #[test]\n fn env_show_truncates_long_values_to_requested_width() {\n assert_eq!(truncate_text(\"abcdefgh\", 5), \"abcd…\");\n let (value, description) = fit_env_row(\n \"abcdefghijklmnopqrstuvwxyz\",\n \"A description that is also fairly long\",\n 8,\n 48,\n );\n assert!(value.chars().count() <= env_value_width(8, 48));\n assert!(description.chars().count() <= 48);\n }\n\n #[test]\n fn env_export_uses_alias_as_variable_name() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"MY_ALIAS\", value),\n \"export MY_ALIAS='secret123'\"\n );\n }\n\n #[test]\n fn env_export_alias_formats_powershell_correctly() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"MY_ALIAS\", value),\n \"$env:MY_ALIAS = 'secret123'\"\n );\n }\n\n #[tokio::test]\n async fn env_delete_removes_key_from_saved_config() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n config.env.insert(\"MY_TOKEN\".into(), \"secret\".into());\n config.save().await.unwrap();\n\n handle_delete(&config, \"MY_TOKEN\").await.unwrap();\n\n let contents = fs::read_to_string(config.config_path()).await.unwrap();\n let parsed: toml::Table = toml::from_str(&contents).unwrap();\n let env = parsed\n .get(\"env\")\n .and_then(|value| value.as_table())\n .unwrap();\n assert!(\n !env.contains_key(\"MY_TOKEN\"),\n \"deleted key should not remain in saved config: {contents}\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_fails_when_key_is_missing() {\n let dir = make_temp_dir().await;\n let config = config_in(&dir);\n\n let err = handle_delete(&config, \"MY_TOKEN\").await.unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN is not set in the active config [env]\"),\n \"error should explain missing key: {err:#}\"\n );\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[test]\n fn env_export_secret_key_appends_secret_suffix() {\n assert_eq!(\n env_export_secret_key(\"DEEPSEEK_API_KEY\"),\n \"DEEPSEEK_API_KEY_SECRET\"\n );\n assert_eq!(env_export_secret_key(\"xxx\"), \"xxx_SECRET\");\n }\n\n #[test]\n fn env_export_resolves_secret_when_present() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_falls_back_to_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Plaintext(\"plain\")\n );\n }\n\n #[test]\n fn env_export_secret_wins_over_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_reports_both_missing_keys() {\n let env = EnvConfig::default();\n\n let err = resolve_env_export_value(&env, \"MY_TOKEN\").unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]\"),\n \"error should explain both checked keys: {err:#}\"\n );\n }\n\n #[test]\n fn env_export_key_validation_accepts_shell_variable_names() {\n for key in [\"FOO\", \"_FOO\", \"foo_123\", \"A1\"] {\n validate_env_export_key(key).unwrap();\n }\n }\n\n #[test]\n fn env_export_key_validation_rejects_unsafe_names() {\n for key in [\"\", \"1FOO\", \"FOO-BAR\", \"FOO;BAR\", \"FOO BAR\", \"FOO.SECRET\"] {\n assert!(\n validate_env_export_key(key).is_err(),\n \"key should be rejected: {key}\"\n );\n }\n }\n\n #[test]\n fn env_export_formats_posix_shell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"TOKEN\", value),\n \"export TOKEN='abc def'\\\\''ghi$HOME\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_fish_shell_code_safely() {\n let value = \"abc def'ghi\\\\path\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Fish, \"TOKEN\", value),\n \"set -gx TOKEN 'abc def\\\\'ghi\\\\\\\\path\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_powershell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; Remove-Item /\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"TOKEN\", value),\n \"$env:TOKEN = 'abc def''ghi$HOME\\nnext; Remove-Item /'\"\n );\n }\n\n #[test]\n fn env_encrypt_output_defaults_from_key_to_secret_key() {\n assert_eq!(\n resolve_env_encrypt_output(None, Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"GH_TOKEN_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_explicit_set_wins_over_default() {\n assert_eq!(\n resolve_env_encrypt_output(Some(\"CUSTOM_SECRET\"), Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"CUSTOM_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_prints_stdin_without_set() {\n assert_eq!(\n resolve_env_encrypt_output(None, None).unwrap(),\n EnvEncryptOutput::Print\n );\n }\n\n #[test]\n fn env_encrypt_output_rejects_invalid_inferred_from_key() {\n let err = resolve_env_encrypt_output(None, Some(\"GH-TOKEN\")).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"env export key must contain only letters, digits, and underscores\"),\n \"error should explain invalid inferred key: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_backend_cli_wins_over_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, Some(\"gpg\")).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_backend_falls_back_to_config() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.secret_backend = Some(\"age\".to_string());\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Age\n );\n }\n\n #[test]\n fn encrypt_backend_defaults_to_gpg() {\n let dir = std::env::temp_dir().join(format!(\"shine-env-backend-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n assert_eq!(\n resolve_encrypt_backend(&config, None).unwrap(),\n BackendKind::Gpg\n );\n }\n\n #[test]\n fn encrypt_recipients_cli_wins_over_config_for_gpg() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"cli@example.com\".to_string()], &config)\n .unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"cli@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n let recipients = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Gpg(values) => assert_eq!(values, vec![\"config@example.com\"]),\n EncryptRecipients::Age(_) => panic!(\"expected gpg recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_gpg_treats_empty_config_as_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\" \".to_string());\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_gpg_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient, set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_age_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.age_recipients = vec![\"age1qexample\".to_string()];\n\n let recipients = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap();\n\n match recipients {\n EncryptRecipients::Age(values) => assert_eq!(values, vec![\"age1qexample\"]),\n EncryptRecipients::Gpg(_) => panic!(\"expected age recipients\"),\n }\n }\n\n #[test]\n fn encrypt_recipients_age_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap_err();\n\n assert!(\n err.to_string().contains(\"age recipients are required\"),\n \"error should explain how to set age recipients: {err:#}\"\n );\n }\n\n #[test]\n fn encrypt_recipients_hints_when_age_recipient_used_with_gpg_backend() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err =\n resolve_encrypt_recipients(BackendKind::Gpg, &[\"age1qexample\".to_string()], &config)\n .unwrap_err();\n\n assert!(\n err.to_string().contains(\"did you mean --backend age\"),\n \"error should hint at the age backend: {err:#}\"\n );\n }\n}\n"}, "7f7e7397f2bfecc7989fce6ab3ba213393360599": {"t": 1785750195.6705577, "n": 19208, "h": "f262cb5a9453b5bd", "c": "//! Handlers for `shine env show/set/delete/get/decrypt/export/encrypt`.\n\nuse anyhow::{Context, Result, bail};\n\nuse super::{EnvConfig, StoredValue, resolve_stored_value, secret_key};\nuse crate::config::Config;\nuse crate::{colors, path_display, secret, shells};\n\npub async fn handle_show(config: &Config, reveal: bool) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let catalog = super::catalog::load(config).await?;\n let terminal_width = usize::from(console::Term::stdout().size().1).max(40);\n let key_width = env\n .iter()\n .map(|(key, _)| key.chars().count())\n .max()\n .unwrap_or(0);\n\n println!(\"{}\", colors::bold(\"Environment\"));\n println!();\n if env.as_map().is_empty() {\n println!(\" {}\", colors::dim(\"No variables configured.\"));\n }\n for (k, v) in env.iter() {\n let metadata = catalog.get(k);\n let description = env\n .description(k)\n .or_else(|| metadata.map(|item| item.description.as_str()))\n .unwrap_or_default();\n let sensitive = metadata.is_some_and(|item| item.sensitive) || is_sensitive_env_key(k);\n let display_value = display_env_value(v, sensitive, reveal);\n let (display_value, description) =\n fit_env_row(&display_value, description, key_width, terminal_width);\n let key_padding = \" \".repeat(key_width.saturating_sub(k.chars().count()));\n if description.is_empty() {\n println!(\" {}{} {}\", colors::cyan(k), key_padding, display_value);\n } else {\n println!(\n \" {}{} {:<value_width$} {}\",\n colors::cyan(k),\n key_padding,\n display_value,\n colors::dim(&description),\n value_width = env_value_width(key_width, terminal_width),\n );\n }\n }\n println!();\n println!(\n \" {} {}\",\n colors::dim(\"Config\"),\n colors::dim(&path_display::format(config.config_path()))\n );\n println!(\n \" {}\",\n colors::dim(&format!(\"{} variables\", env.as_map().len()))\n );\n Ok(())\n}\n\nfn is_sensitive_env_key(key: &str) -> bool {\n let key = key.to_ascii_uppercase();\n [\n \"SECRET\",\n \"TOKEN\",\n \"PASSWORD\",\n \"PASSPHRASE\",\n \"API_KEY\",\n \"PRIVATE_KEY\",\n \"ACCESS_KEY\",\n ]\n .iter()\n .any(|suffix| key == *suffix || key.ends_with(&format!(\"_{suffix}\")))\n}\n\nfn display_env_value(value: &str, sensitive: bool, reveal: bool) -> String {\n if value.is_empty() {\n \"<empty>\".to_string()\n } else if sensitive && !reveal {\n \"<redacted>\".to_string()\n } else {\n value.to_string()\n }\n}\n\nfn env_value_width(key_width: usize, terminal_width: usize) -> usize {\n terminal_width.saturating_sub(key_width + 28).clamp(12, 36)\n}\n\nfn fit_env_row(\n value: &str,\n description: &str,\n key_width: usize,\n terminal_width: usize,\n) -> (String, String) {\n let value_width = env_value_width(key_width, terminal_width);\n let value = truncate_text(value, value_width);\n let description_width = terminal_width.saturating_sub(2 + key_width + 2 + value_width + 2);\n let description = if description_width < 8 {\n String::new()\n } else {\n truncate_text(description, description_width)\n };\n (value, description)\n}\n\nfn truncate_text(value: &str, max_width: usize) -> String {\n if value.chars().count() <= max_width {\n return value.to_string();\n }\n if max_width <= 1 {\n return \"…\".to_string();\n }\n let mut result = value.chars().take(max_width - 1).collect::<String>();\n result.push('…');\n result\n}\n\npub async fn handle_set(config: &Config, key: &str, value: &str) -> Result<()> {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(key, value);\n env.save(config).await?;\n println!(\"{}\", colors::green(&format!(\"set {key} = \\\"{value}\\\"\")));\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_delete(config: &Config, key: &str) -> Result<()> {\n let mut env = EnvConfig::load_or_init(config).await?;\n if env.remove(key).is_none() {\n bail!(\"{key} is not set in the active config [env]\");\n }\n env.save(config).await?;\n println!(\"{}\", colors::green(&format!(\"deleted {key}\")));\n println!(\n \"{}\",\n colors::dim(\"Run `shine upgrade` to apply to already-installed presets.\")\n );\n Ok(())\n}\n\npub async fn handle_get(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n match env.get(key) {\n Some(v) => println!(\"{v}\"),\n None => {\n eprintln!(\n \"{}\",\n colors::yellow(&format!(\"{key} is not set in the active config [env]\"))\n );\n std::process::exit(1);\n }\n }\n Ok(())\n}\n\npub async fn handle_decrypt(config: &Config, key: &str) -> Result<()> {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n let plaintext = secret::decrypt_base64_gpg_secret(value)\n .await\n .with_context(|| format!(\"decrypting {key}\"))?;\n print!(\"{plaintext}\");\n Ok(())\n}\n\npub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {\n validate_env_export_key(key)?;\n if let Some(alias) = alias {\n validate_env_export_key(alias)?;\n }\n let env = EnvConfig::load_or_init(config).await?;\n let value = match resolve_env_export_value(&env, key)? {\n EnvExportValue::Secret {\n key: secret_key,\n value,\n } => secret::decrypt_base64_gpg_secret(value)\n .await\n .with_context(|| format!(\"decrypting {secret_key}\"))?,\n EnvExportValue::Plaintext(value) => value.to_string(),\n };\n let export_as = alias.unwrap_or(key);\n println!(\n \"{}\",\n format_env_export(&config.shell_type, export_as, &value)\n );\n Ok(())\n}\n\ntype EnvExportValue<'a> = StoredValue<'a>;\n\nfn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {\n resolve_stored_value(env, key)\n}\n\nfn env_export_secret_key(key: &str) -> String {\n secret_key(key)\n}\n\nfn validate_env_export_key(key: &str) -> Result<()> {\n let mut chars = key.chars();\n let Some(first) = chars.next() else {\n bail!(\"env export key must not be empty\");\n };\n if !(first == '_' || first.is_ascii_alphabetic()) {\n bail!(\"env export key must start with a letter or underscore: {key}\");\n }\n if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {\n bail!(\"env export key must contain only letters, digits, and underscores: {key}\");\n }\n Ok(())\n}\n\nfn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {\n match shell {\n shells::ShellType::Fish => format!(\"set -gx {key} {}\", fish_quote(value)),\n shells::ShellType::PowerShell => {\n format!(\"$env:{key} = {}\", powershell_string_quote(value))\n }\n _ => format!(\"export {key}={}\", posix_shell_quote(value)),\n }\n}\n\nfn posix_shell_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"'\\\\''\"))\n}\n\nfn fish_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\\\', \"\\\\\\\\\").replace('\\'', \"\\\\'\"))\n}\n\nfn powershell_string_quote(value: &str) -> String {\n format!(\"'{}'\", value.replace('\\'', \"''\"))\n}\n\n#[derive(Debug, PartialEq, Eq)]\nenum EnvEncryptOutput {\n Print,\n Set(String),\n}\n\nfn resolve_env_encrypt_output(\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<EnvEncryptOutput> {\n if let Some(key) = set_key {\n return Ok(EnvEncryptOutput::Set(key.to_string()));\n }\n if let Some(key) = from_key {\n validate_env_export_key(key)?;\n return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));\n }\n Ok(EnvEncryptOutput::Print)\n}\n\nfn resolve_env_encrypt_recipient(config: &Config, recipient: Option<&str>) -> Result<String> {\n if let Some(recipient) = recipient\n .map(str::trim)\n .filter(|recipient| !recipient.is_empty())\n {\n return Ok(recipient.to_string());\n }\n if let Some(recipient) = config\n .gpg_key_id\n .as_deref()\n .map(str::trim)\n .filter(|recipient| !recipient.is_empty())\n {\n return Ok(recipient.to_string());\n }\n bail!(\"GPG recipient is required; pass -r/--recipient or set gpg_key_id in config.toml\");\n}\n\npub async fn handle_encrypt(\n config: &Config,\n recipient: Option<&str>,\n set_key: Option<&str>,\n from_key: Option<&str>,\n) -> Result<()> {\n use std::io::Read as _;\n\n let recipient = resolve_env_encrypt_recipient(config, recipient)?;\n let plaintext = if let Some(key) = from_key {\n let env = EnvConfig::load_or_init(config).await?;\n let Some(value) = env.get(key) else {\n bail!(\"{key} is not set in the active config [env]\");\n };\n value.as_bytes().to_vec()\n } else {\n let mut input = Vec::new();\n std::io::stdin()\n .read_to_end(&mut input)\n .context(\"reading secret from stdin\")?;\n input\n };\n let encoded = secret::encrypt_gpg_secret_to_base64(&plaintext, &recipient)\n .await\n .with_context(|| format!(\"encrypting secret for {recipient}\"))?;\n match resolve_env_encrypt_output(set_key, from_key)? {\n EnvEncryptOutput::Set(key) => {\n let mut env = EnvConfig::load_or_init(config).await?;\n env.set(&key, &encoded);\n env.save(config).await?;\n println!(\"{}\", colors::green(&format!(\"set {key} = \\\"{encoded}\\\"\")));\n }\n EnvEncryptOutput::Print => println!(\"{encoded}\"),\n }\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::config::Config;\n use tokio::fs;\n\n async fn make_temp_dir() -> std::path::PathBuf {\n let dir = std::env::temp_dir().join(format!(\"shine-env-cmd-test-{}\", uuid::Uuid::new_v4()));\n fs::create_dir_all(&dir).await.unwrap();\n dir\n }\n\n fn config_in(dir: &std::path::Path) -> Config {\n Config::new_for_test(dir)\n }\n\n #[test]\n fn env_show_redacts_sensitive_values() {\n assert_eq!(display_env_value(\"secret\", true, false), \"<redacted>\");\n assert_eq!(display_env_value(\"secret\", true, true), \"secret\");\n assert_eq!(display_env_value(\"\", true, false), \"<empty>\");\n assert!(is_sensitive_env_key(\"MY_API_KEY\"));\n assert!(is_sensitive_env_key(\"token\"));\n assert!(!is_sensitive_env_key(\"MONKEY\"));\n }\n\n #[test]\n fn env_show_truncates_long_values_to_requested_width() {\n assert_eq!(truncate_text(\"abcdefgh\", 5), \"abcd…\");\n let (value, description) = fit_env_row(\n \"abcdefghijklmnopqrstuvwxyz\",\n \"A description that is also fairly long\",\n 8,\n 48,\n );\n assert!(value.chars().count() <= env_value_width(8, 48));\n assert!(description.chars().count() <= 48);\n }\n\n #[test]\n fn env_export_uses_alias_as_variable_name() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"MY_ALIAS\", value),\n \"export MY_ALIAS='secret123'\"\n );\n }\n\n #[test]\n fn env_export_alias_formats_powershell_correctly() {\n let value = \"secret123\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"MY_ALIAS\", value),\n \"$env:MY_ALIAS = 'secret123'\"\n );\n }\n\n #[tokio::test]\n async fn env_delete_removes_key_from_saved_config() {\n let dir = make_temp_dir().await;\n let mut config = config_in(&dir);\n config.env.insert(\"MY_TOKEN\".into(), \"secret\".into());\n config.save().await.unwrap();\n\n handle_delete(&config, \"MY_TOKEN\").await.unwrap();\n\n let contents = fs::read_to_string(config.config_path()).await.unwrap();\n let parsed: toml::Table = toml::from_str(&contents).unwrap();\n let env = parsed\n .get(\"env\")\n .and_then(|value| value.as_table())\n .unwrap();\n assert!(\n !env.contains_key(\"MY_TOKEN\"),\n \"deleted key should not remain in saved config: {contents}\"\n );\n\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[tokio::test]\n async fn env_delete_fails_when_key_is_missing() {\n let dir = make_temp_dir().await;\n let config = config_in(&dir);\n\n let err = handle_delete(&config, \"MY_TOKEN\").await.unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN is not set in the active config [env]\"),\n \"error should explain missing key: {err:#}\"\n );\n fs::remove_dir_all(&dir).await.unwrap();\n }\n\n #[test]\n fn env_export_secret_key_appends_secret_suffix() {\n assert_eq!(\n env_export_secret_key(\"DEEPSEEK_API_KEY\"),\n \"DEEPSEEK_API_KEY_SECRET\"\n );\n assert_eq!(env_export_secret_key(\"xxx\"), \"xxx_SECRET\");\n }\n\n #[test]\n fn env_export_resolves_secret_when_present() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_falls_back_to_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Plaintext(\"plain\")\n );\n }\n\n #[test]\n fn env_export_secret_wins_over_plaintext_value() {\n let mut env = EnvConfig::default();\n env.set(\"MY_TOKEN\", \"plain\");\n env.set(\"MY_TOKEN_SECRET\", \"encrypted\");\n\n assert_eq!(\n resolve_env_export_value(&env, \"MY_TOKEN\").unwrap(),\n EnvExportValue::Secret {\n key: \"MY_TOKEN_SECRET\".to_string(),\n value: \"encrypted\"\n }\n );\n }\n\n #[test]\n fn env_export_reports_both_missing_keys() {\n let env = EnvConfig::default();\n\n let err = resolve_env_export_value(&env, \"MY_TOKEN\").unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]\"),\n \"error should explain both checked keys: {err:#}\"\n );\n }\n\n #[test]\n fn env_export_key_validation_accepts_shell_variable_names() {\n for key in [\"FOO\", \"_FOO\", \"foo_123\", \"A1\"] {\n validate_env_export_key(key).unwrap();\n }\n }\n\n #[test]\n fn env_export_key_validation_rejects_unsafe_names() {\n for key in [\"\", \"1FOO\", \"FOO-BAR\", \"FOO;BAR\", \"FOO BAR\", \"FOO.SECRET\"] {\n assert!(\n validate_env_export_key(key).is_err(),\n \"key should be rejected: {key}\"\n );\n }\n }\n\n #[test]\n fn env_export_formats_posix_shell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Zsh, \"TOKEN\", value),\n \"export TOKEN='abc def'\\\\''ghi$HOME\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_fish_shell_code_safely() {\n let value = \"abc def'ghi\\\\path\\nnext; rm -rf /\";\n assert_eq!(\n format_env_export(&shells::ShellType::Fish, \"TOKEN\", value),\n \"set -gx TOKEN 'abc def\\\\'ghi\\\\\\\\path\\nnext; rm -rf /'\"\n );\n }\n\n #[test]\n fn env_export_formats_powershell_code_safely() {\n let value = \"abc def'ghi$HOME\\nnext; Remove-Item /\";\n assert_eq!(\n format_env_export(&shells::ShellType::PowerShell, \"TOKEN\", value),\n \"$env:TOKEN = 'abc def''ghi$HOME\\nnext; Remove-Item /'\"\n );\n }\n\n #[test]\n fn env_encrypt_output_defaults_from_key_to_secret_key() {\n assert_eq!(\n resolve_env_encrypt_output(None, Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"GH_TOKEN_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_explicit_set_wins_over_default() {\n assert_eq!(\n resolve_env_encrypt_output(Some(\"CUSTOM_SECRET\"), Some(\"GH_TOKEN\")).unwrap(),\n EnvEncryptOutput::Set(\"CUSTOM_SECRET\".to_string())\n );\n }\n\n #[test]\n fn env_encrypt_output_prints_stdin_without_set() {\n assert_eq!(\n resolve_env_encrypt_output(None, None).unwrap(),\n EnvEncryptOutput::Print\n );\n }\n\n #[test]\n fn env_encrypt_output_rejects_invalid_inferred_from_key() {\n let err = resolve_env_encrypt_output(None, Some(\"GH-TOKEN\")).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"env export key must contain only letters, digits, and underscores\"),\n \"error should explain invalid inferred key: {err:#}\"\n );\n }\n\n #[test]\n fn env_encrypt_recipient_cli_wins_over_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n assert_eq!(\n resolve_env_encrypt_recipient(&config, Some(\"cli@example.com\")).unwrap(),\n \"cli@example.com\"\n );\n }\n\n #[test]\n fn env_encrypt_recipient_falls_back_to_config() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\"config@example.com\".to_string());\n\n assert_eq!(\n resolve_env_encrypt_recipient(&config, None).unwrap(),\n \"config@example.com\"\n );\n }\n\n #[test]\n fn env_encrypt_recipient_treats_empty_config_as_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let mut config = config_in(&dir);\n config.gpg_key_id = Some(\" \".to_string());\n\n let err = resolve_env_encrypt_recipient(&config, None).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient or set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n\n #[test]\n fn env_encrypt_recipient_errors_when_missing() {\n let dir =\n std::env::temp_dir().join(format!(\"shine-env-recipient-{}\", uuid::Uuid::new_v4()));\n let config = config_in(&dir);\n\n let err = resolve_env_encrypt_recipient(&config, None).unwrap_err();\n\n assert!(\n err.to_string()\n .contains(\"pass -r/--recipient or set gpg_key_id\"),\n \"error should explain how to set recipient: {err:#}\"\n );\n }\n}\n"}}}}