use crate::store::script::Script;
use eyre::Result;
use std::collections::{HashMap, HashSet};
use std::process::Stdio;
use tempfile::NamedTempFile;
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::sync::mpsc;
use tokio::task;
use tracing::debug;
pub fn build_executable_script(script: String, shebang: String) -> String {
if shebang.is_empty() {
format!("#!/usr/bin/env bash\n{script}")
} else if script.starts_with("#!") {
format!("{shebang}\n{script}")
} else {
format!("#!{shebang}\n{script}")
}
}
pub struct ScriptSession {
pub stdin_tx: mpsc::Sender<String>,
pub exit_code_rx: mpsc::Receiver<i32>,
}
impl ScriptSession {
pub async fn send_input(&self, input: String) -> Result<(), mpsc::error::SendError<String>> {
self.stdin_tx.send(input).await
}
pub async fn wait_for_exit(&mut self) -> Option<i32> {
self.exit_code_rx.recv().await
}
}
fn setup_template(script: &Script) -> Result<minijinja::Environment<'_>> {
let mut env = minijinja::Environment::new();
env.set_trim_blocks(true);
env.add_template("script", script.script.as_str())?;
Ok(env)
}
pub fn template_script(
script: &Script,
context: &HashMap<String, serde_json::Value>,
) -> Result<String> {
let env = setup_template(script)?;
let template = env.get_template("script")?;
let rendered = template.render(context)?;
Ok(rendered)
}
pub fn template_variables(script: &Script) -> Result<HashSet<String>> {
let env = setup_template(script)?;
let template = env.get_template("script")?;
Ok(template.undeclared_variables(true))
}
pub async fn execute_script_interactive(
script: String,
shebang: String,
) -> Result<ScriptSession, Box<dyn std::error::Error + Send + Sync>> {
let temp_file = NamedTempFile::new()?;
let temp_path = temp_file.path().to_path_buf();
debug!("creating temp file at {}", temp_path.display());
let interpreter = if !shebang.is_empty() {
shebang.trim_start_matches("#!").trim().to_string()
} else {
"/usr/bin/env bash".to_string()
};
let full_script_content = build_executable_script(script.clone(), shebang.clone());
debug!("writing script content to temp file");
tokio::fs::write(&temp_path, &full_script_content).await?;
#[cfg(unix)]
{
debug!("making script executable");
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&temp_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&temp_path, perms)?;
}
let _keep_temp_file = temp_file;
debug!("attempting direct script execution");
let mut child_result = tokio::process::Command::new(temp_path.to_str().unwrap())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
if let Err(e) = &child_result {
debug!("direct execution failed: {}, trying with interpreter", e);
debug!("writing script content without shebang for interpreter execution");
tokio::fs::write(&temp_path, &script).await?;
let parts: Vec<&str> = interpreter.split_whitespace().collect();
if !parts.is_empty() {
let mut cmd = tokio::process::Command::new(parts[0]);
for i in parts.iter().skip(1) {
cmd.arg(i);
}
cmd.arg(temp_path.to_str().unwrap());
child_result = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
}
}
let mut child = match child_result {
Ok(child) => child,
Err(e) => {
return Err(format!("Failed to execute script: {e}").into());
}
};
let mut stdin = child
.stdin
.take()
.ok_or_else(|| "Failed to open child process stdin".to_string())?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "Failed to open child process stdout".to_string())?;
let stderr = child
.stderr
.take()
.ok_or_else(|| "Failed to open child process stderr".to_string())?;
let (stdin_tx, mut stdin_rx) = mpsc::channel::<String>(32);
let (exit_code_tx, exit_code_rx) = mpsc::channel::<i32>(1);
debug!("spawning stdin handler");
tokio::spawn(async move {
while let Some(input) = stdin_rx.recv().await {
if let Err(e) = stdin.write_all(input.as_bytes()).await {
eprintln!("Error writing to stdin: {e}");
break;
}
if let Err(e) = stdin.flush().await {
eprintln!("Error flushing stdin: {e}");
break;
}
}
});
debug!("spawning stdout handler");
let stdout_handle = task::spawn(async move {
let mut stdout_reader = BufReader::new(stdout);
let mut buffer = [0u8; 1024];
let mut stdout_writer = tokio::io::stdout();
loop {
match stdout_reader.read(&mut buffer).await {
Ok(0) => break, Ok(n) => {
if let Err(e) = stdout_writer.write_all(&buffer[0..n]).await {
eprintln!("Error writing to stdout: {e}");
break;
}
if let Err(e) = stdout_writer.flush().await {
eprintln!("Error flushing stdout: {e}");
break;
}
}
Err(e) => {
eprintln!("Error reading from process stdout: {e}");
break;
}
}
}
});
debug!("spawning stderr handler");
let stderr_handle = task::spawn(async move {
let mut stderr_reader = BufReader::new(stderr);
let mut buffer = [0u8; 1024];
let mut stderr_writer = tokio::io::stderr();
loop {
match stderr_reader.read(&mut buffer).await {
Ok(0) => break, Ok(n) => {
if let Err(e) = stderr_writer.write_all(&buffer[0..n]).await {
eprintln!("Error writing to stderr: {e}");
break;
}
if let Err(e) = stderr_writer.flush().await {
eprintln!("Error flushing stderr: {e}");
break;
}
}
Err(e) => {
eprintln!("Error reading from process stderr: {e}");
break;
}
}
}
});
debug!("spawning exit code handler");
let _keep_temp_file_clone = _keep_temp_file;
tokio::spawn(async move {
let _temp_file_ref = _keep_temp_file_clone;
let status = match child.wait().await {
Ok(status) => {
debug!("Process exited with status: {:?}", status);
status
}
Err(e) => {
eprintln!("Error waiting for child process: {e}");
let _ = exit_code_tx.send(-1).await;
return;
}
};
if let Err(e) = stdout_handle.await {
eprintln!("Error joining stdout task: {e}");
}
if let Err(e) = stderr_handle.await {
eprintln!("Error joining stderr task: {e}");
}
let exit_code = status.code().unwrap_or(-1);
debug!("Sending exit code: {}", exit_code);
let _ = exit_code_tx.send(exit_code).await;
});
Ok(ScriptSession {
stdin_tx,
exit_code_rx,
})
}