初始化提交
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
This commit is contained in:
241
crates/openfang-cli/src/dotenv.rs
Normal file
241
crates/openfang-cli/src/dotenv.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
//! Minimal `.env` file loader/saver for `~/.openfang/.env`.
|
||||
//!
|
||||
//! No external crate needed — hand-rolled for simplicity.
|
||||
//! Format: `KEY=VALUE` lines, `#` comments, optional quotes.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Return the path to `~/.openfang/.env`.
|
||||
pub fn env_file_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".openfang").join(".env"))
|
||||
}
|
||||
|
||||
/// Load `~/.openfang/.env` and `~/.openfang/secrets.env` into `std::env`.
|
||||
///
|
||||
/// System env vars take priority — existing vars are NOT overridden.
|
||||
/// `secrets.env` is loaded second so `.env` values take priority over secrets
|
||||
/// (but both yield to system env vars).
|
||||
/// Silently does nothing if the files don't exist.
|
||||
pub fn load_dotenv() {
|
||||
load_env_file(env_file_path());
|
||||
// Also load secrets.env (written by dashboard "Set API Key" button)
|
||||
load_env_file(secrets_env_path());
|
||||
}
|
||||
|
||||
/// Return the path to `~/.openfang/secrets.env`.
|
||||
pub fn secrets_env_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".openfang").join("secrets.env"))
|
||||
}
|
||||
|
||||
fn load_env_file(path: Option<PathBuf>) {
|
||||
let path = match path {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((key, value)) = parse_env_line(trimmed) {
|
||||
if std::env::var(&key).is_err() {
|
||||
std::env::set_var(&key, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upsert a key in `~/.openfang/.env`.
|
||||
///
|
||||
/// Creates the file if missing. Sets 0600 permissions on Unix.
|
||||
/// Also sets the key in the current process environment.
|
||||
pub fn save_env_key(key: &str, value: &str) -> Result<(), String> {
|
||||
let path = env_file_path().ok_or("Could not determine home directory")?;
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||
}
|
||||
|
||||
let mut entries = read_env_file(&path);
|
||||
entries.insert(key.to_string(), value.to_string());
|
||||
write_env_file(&path, &entries)?;
|
||||
|
||||
// Also set in current process
|
||||
std::env::set_var(key, value);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a key from `~/.openfang/.env`.
|
||||
///
|
||||
/// Also removes it from the current process environment.
|
||||
pub fn remove_env_key(key: &str) -> Result<(), String> {
|
||||
let path = env_file_path().ok_or("Could not determine home directory")?;
|
||||
|
||||
let mut entries = read_env_file(&path);
|
||||
entries.remove(key);
|
||||
write_env_file(&path, &entries)?;
|
||||
|
||||
std::env::remove_var(key);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List key names (without values) from `~/.openfang/.env`.
|
||||
#[allow(dead_code)]
|
||||
pub fn list_env_keys() -> Vec<String> {
|
||||
let path = match env_file_path() {
|
||||
Some(p) => p,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
read_env_file(&path).into_keys().collect()
|
||||
}
|
||||
|
||||
/// Check if the `.env` file exists.
|
||||
#[allow(dead_code)]
|
||||
pub fn env_file_exists() -> bool {
|
||||
env_file_path().map(|p| p.exists()).unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a single `KEY=VALUE` line. Handles optional quotes.
|
||||
fn parse_env_line(line: &str) -> Option<(String, String)> {
|
||||
let eq_pos = line.find('=')?;
|
||||
let key = line[..eq_pos].trim().to_string();
|
||||
let mut value = line[eq_pos + 1..].trim().to_string();
|
||||
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Strip matching quotes
|
||||
if ((value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\'')))
|
||||
&& value.len() >= 2
|
||||
{
|
||||
value = value[1..value.len() - 1].to_string();
|
||||
}
|
||||
|
||||
Some((key, value))
|
||||
}
|
||||
|
||||
/// Read all key-value pairs from the .env file.
|
||||
fn read_env_file(path: &PathBuf) -> BTreeMap<String, String> {
|
||||
let mut map = BTreeMap::new();
|
||||
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return map,
|
||||
};
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = parse_env_line(trimmed) {
|
||||
map.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
/// Write key-value pairs back to the .env file with a header comment.
|
||||
fn write_env_file(path: &PathBuf, entries: &BTreeMap<String, String>) -> Result<(), String> {
|
||||
let mut content =
|
||||
String::from("# OpenFang environment — managed by `openfang config set-key`\n");
|
||||
content.push_str("# Do not edit while the daemon is running.\n\n");
|
||||
|
||||
for (key, value) in entries {
|
||||
// Quote values that contain spaces or special characters
|
||||
if value.contains(' ') || value.contains('#') || value.contains('"') {
|
||||
content.push_str(&format!("{key}=\"{}\"\n", value.replace('"', "\\\"")));
|
||||
} else {
|
||||
content.push_str(&format!("{key}={value}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::write(path, &content).map_err(|e| format!("Failed to write .env file: {e}"))?;
|
||||
|
||||
// Set 0600 permissions on Unix
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_simple() {
|
||||
let (k, v) = parse_env_line("FOO=bar").unwrap();
|
||||
assert_eq!(k, "FOO");
|
||||
assert_eq!(v, "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_quoted() {
|
||||
let (k, v) = parse_env_line("KEY=\"hello world\"").unwrap();
|
||||
assert_eq!(k, "KEY");
|
||||
assert_eq!(v, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_single_quoted() {
|
||||
let (k, v) = parse_env_line("KEY='value'").unwrap();
|
||||
assert_eq!(k, "KEY");
|
||||
assert_eq!(v, "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_spaces() {
|
||||
let (k, v) = parse_env_line(" KEY = value ").unwrap();
|
||||
assert_eq!(k, "KEY");
|
||||
assert_eq!(v, "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_no_value() {
|
||||
let (k, v) = parse_env_line("KEY=").unwrap();
|
||||
assert_eq!(k, "KEY");
|
||||
assert_eq!(v, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_comment() {
|
||||
assert!(
|
||||
parse_env_line("# comment").is_none()
|
||||
|| parse_env_line("# comment").unwrap().0.starts_with('#')
|
||||
);
|
||||
// Comments are filtered before reaching parse_env_line in production code
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_no_equals() {
|
||||
assert!(parse_env_line("NOEQUALS").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_empty_key() {
|
||||
assert!(parse_env_line("=value").is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user