feat: add ssh-agent flow

This commit is contained in:
2026-08-23 06:10:15 +05:00
parent eeb5aa9e84
commit dd7151bca3
2 changed files with 162 additions and 0 deletions
+106
View File
@@ -2,10 +2,16 @@ use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result, bail};
use std::fs;
pub fn execute(cmd: &str, workdir: &Path) -> Result<()> {
let mut command = shell_command(cmd);
command.current_dir(workdir);
for (key, value) in load_dotenv(workdir)? {
if std::env::var_os(&key).is_none() {
command.env(key, value);
}
}
let status = command
.status()
@@ -21,6 +27,49 @@ pub fn execute(cmd: &str, workdir: &Path) -> Result<()> {
Ok(())
}
fn load_dotenv(workdir: &Path) -> Result<Vec<(String, String)>> {
let path = workdir.join(".env");
if !path.is_file() {
return Ok(Vec::new());
}
let raw =
fs::read_to_string(&path).with_context(|| format!("cannot read {}", path.display()))?;
let mut vars = Vec::new();
for (num, line) in raw.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line).trim_start();
let Some((key, value)) = line.split_once('=') else {
bail!(".env line {}: expected KEY=VALUE", num + 1);
};
validate_var_name(key, num)?;
vars.push((key.to_string(), unquote(value.trim())));
}
Ok(vars)
}
fn validate_var_name(key: &str, line: usize) -> Result<()> {
let valid = !key.is_empty()
&& !key.chars().next().unwrap().is_ascii_digit()
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !valid {
bail!(".env line {line}: invalid variable name '{key}'");
}
Ok(())
}
fn unquote(mut value: &str) -> String {
for quote in ['"', '\''] {
if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
value = &value[1..value.len() - 1];
break;
}
}
value.to_string()
}
fn shell_command(cmd: &str) -> Command {
#[cfg(target_os = "windows")]
{
@@ -118,4 +167,61 @@ mod tests {
let err = execute(&ok_cmd(), &missing).unwrap_err();
assert!(err.to_string().contains("failed to spawn"), "{err:#}");
}
#[test]
fn dotenv_parses_comments_quotes_and_export() {
let workdir = tmpdir("dotenv");
fs::write(
workdir.join(".env"),
"# comment\nDB_HOST=localhost\nexport API_KEY=\"abc=123\"\nSINGLE='v v'\n\n",
)
.unwrap();
let vars = load_dotenv(&workdir).unwrap();
assert_eq!(
vars,
vec![
("DB_HOST".to_string(), "localhost".to_string()),
("API_KEY".to_string(), "abc=123".to_string()),
("SINGLE".to_string(), "v v".to_string()),
]
);
let _ = fs::remove_dir_all(workdir);
}
#[test]
fn dotenv_invalid_line_is_clear_error() {
let workdir = tmpdir("dotenv-bad");
fs::write(workdir.join(".env"), "GOOD=1\nNO_EQUALS_HERE\n").unwrap();
let err = load_dotenv(&workdir).unwrap_err();
assert!(err.to_string().contains("line 2"), "{err:#}");
let _ = fs::remove_dir_all(workdir);
}
#[test]
fn dotenv_missing_file_is_empty() {
let workdir = tmpdir("dotenv-none");
assert!(load_dotenv(&workdir).unwrap().is_empty());
let _ = fs::remove_dir_all(workdir);
}
#[test]
fn env_vars_visible_inside_local_commands() {
let workdir = tmpdir("dotenv-exec");
let echo_var = if cfg!(windows) {
"echo %XBP_TEST_VAR%"
} else {
"echo $XBP_TEST_VAR"
};
fs::write(workdir.join(".env"), "XBP_TEST_VAR=hello-env\n").unwrap();
let mut command = shell_command(echo_var);
command.current_dir(&workdir);
for (k, v) in load_dotenv(&workdir).unwrap() {
if std::env::var_os(&k).is_none() {
command.env(k, v);
}
}
let out = command.output().unwrap();
assert!(String::from_utf8_lossy(&out.stdout).contains("hello-env"));
let _ = fs::remove_dir_all(workdir);
}
}