Expose (ci.sh cmd opts?) for host-side commands
Spawns a process synchronously, returns {:exit :stdout :stderr}. cmd
accepts a string (run via sh -c) or an argv sequence; opts is a
strictly-typed table — env merges into the inherited environment and
unknown keys fail closed so typos surface instead of being silently
dropped.

Non-zero exits are reported in :exit rather than raised, mirroring the
shape (container …) will eventually use so callers can branch on the
result the same way.

Assisted-by: Claude Opus 4.7 via Claude Code
change ztzwrvwknsumwuupyzxvvwzoypmxmssz
commit f623d7d4c8225c77a6fc617f2f3865b36d8780bc
author Alpha Chen <alpha@kejadlen.dev>
date
parent vosvvvpw
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index 17f68f5..72babfa 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -5,7 +5,7 @@ use std::collections::HashMap;
 use std::rc::Rc;
 
 use miette::{NamedSource, SourceSpan};
-use mlua::Lua;
+use mlua::{Lua, LuaSerdeExt};
 
 use crate::Result;
 use crate::fennel::Fennel;
@@ -104,6 +104,7 @@ impl CiModule {
         let table = lua.create_table()?;
         table.set("job", lua.create_function(register_job)?)?;
         table.set("secret", lua.create_function(lookup_secret)?)?;
+        table.set("sh", lua.create_function(run_sh)?)?;
         Ok(table)
     }
 }
@@ -149,6 +150,115 @@ fn lookup_secret(lua: &Lua, name: String) -> mlua::Result<String> {
         .map_err(mlua::Error::external)
 }
 
+/// The two valid shapes of `cmd` for `(ci.sh cmd …)`. A bare string
+/// runs under `sh -c`; a sequence runs as argv with no shell.
+#[derive(serde::Deserialize)]
+#[serde(untagged)]
+enum Cmd {
+    Shell(String),
+    Argv(Vec<String>),
+}
+
+impl From<Cmd> for std::process::Command {
+    fn from(cmd: Cmd) -> Self {
+        match cmd {
+            Cmd::Shell(s) => {
+                let mut c = std::process::Command::new("sh");
+                c.arg("-c").arg(s);
+                c
+            }
+            Cmd::Argv(argv) => {
+                let mut c = std::process::Command::new(&argv[0]);
+                c.args(&argv[1..]);
+                c
+            }
+        }
+    }
+}
+
+impl Cmd {
+    /// Spawn this command with the given options, blocking until exit,
+    /// and capture the result. Inherits the runner's env with
+    /// `opts.env` merged on top.
+    fn run(self, opts: ShOpts) -> std::io::Result<Output> {
+        let mut command: std::process::Command = self.into();
+        for (k, v) in opts.env {
+            command.env(k, v);
+        }
+        if let Some(cwd) = opts.cwd {
+            command.current_dir(cwd);
+        }
+        let output = command.output()?;
+        Ok(Output {
+            exit: output.status.code().unwrap_or(-1),
+            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
+            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
+        })
+    }
+}
+
+impl mlua::FromLua for Cmd {
+    fn from_lua(value: mlua::Value, lua: &Lua) -> mlua::Result<Self> {
+        // Pre-check the Lua type so wrong-shape inputs get specific
+        // FromLuaConversionError messages — serde's untagged dispatch
+        // would otherwise just say "data did not match any variant".
+        match &value {
+            mlua::Value::String(_) => lua.from_value(value),
+            mlua::Value::Table(t) if t.raw_len() == 0 => {
+                Err(mlua::Error::FromLuaConversionError {
+                    from: "table",
+                    to: "Cmd".into(),
+                    message: Some("ci.sh: argv list is empty".into()),
+                })
+            }
+            mlua::Value::Table(_) => lua.from_value(value),
+            other => Err(mlua::Error::FromLuaConversionError {
+                from: other.type_name(),
+                to: "Cmd".into(),
+                message: Some("ci.sh: cmd must be a string or sequence of strings".into()),
+            }),
+        }
+    }
+}
+
+/// The optional `opts` table for `(ci.sh cmd opts?)`. Unknown keys
+/// fail closed so typos surface rather than being silently ignored.
+#[derive(Default, serde::Deserialize)]
+#[serde(default, deny_unknown_fields)]
+struct ShOpts {
+    env: HashMap<String, String>,
+    cwd: Option<String>,
+}
+
+impl mlua::FromLua for ShOpts {
+    fn from_lua(value: mlua::Value, lua: &Lua) -> mlua::Result<Self> {
+        lua.from_value(value)
+    }
+}
+
+/// The captured outcome of running a process — what `(ci.sh …)`
+/// returns. Crosses the boundary as plain serde data: `lua.to_value`
+/// on the way out, `lua.from_value` on the way in.
+///
+/// A non-zero exit is reported in `:exit`, not raised as a Lua error —
+/// matches the shape `(container …)` will eventually use so callers can
+/// branch on it.
+#[derive(serde::Serialize, serde::Deserialize)]
+struct Output {
+    exit: i32,
+    stdout: String,
+    stderr: String,
+}
+
+/// Body of `(ci.sh cmd opts?)`. Glue between the Lua call and
+/// `Cmd::run` — defaults the opts and converts both directions.
+fn run_sh(lua: &Lua, (cmd, opts): (Cmd, Option<ShOpts>)) -> mlua::Result<mlua::Value> {
+    let output = cmd
+        .run(opts.unwrap_or_default())
+        .map_err(mlua::Error::external)?;
+    lua.to_value(&output)
+}
+
 /// Parse and validate a ci.fnl source string into a `Pipeline`.
 ///
 /// Injects `quire.ci` into `package.loaded` so scripts can
@@ -594,6 +704,129 @@ mod tests {
         );
     }
 
+    /// Build a pipeline whose single job's run-fn invokes `(ci.sh …)`,
+    /// invoke it, and decode the resulting Lua table as Output through
+    /// the same VM via lua.from_value. Owned data, so the Fennel VM can
+    /// drop without a use-after-free.
+    fn run_sh_via_job(source: &str) -> Output {
+        let f = fennel();
+        let pipeline =
+            load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
+        let value: mlua::Value = pipeline.jobs()[0]
+            .run_fn
+            .call(())
+            .expect("ci.sh call should return a value");
+        f.lua().from_value(value).expect("decode Output")
+    }
+
+    #[test]
+    fn ci_sh_runs_argv_and_captures_stdout() {
+        let r = run_sh_via_job(
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh ["echo" "hello"])))"#,
+        );
+        assert_eq!(r.exit, 0);
+        assert_eq!(r.stdout, "hello\n");
+        assert!(r.stderr.is_empty());
+    }
+
+    #[test]
+    fn ci_sh_runs_string_under_shell() {
+        let r = run_sh_via_job(
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh "echo hello | tr a-z A-Z")))"#,
+        );
+        assert_eq!(r.exit, 0);
+        assert_eq!(r.stdout, "HELLO\n");
+    }
+
+    #[test]
+    fn ci_sh_reports_nonzero_exit_without_erroring() {
+        let r = run_sh_via_job(
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh "exit 7")))"#,
+        );
+        assert_eq!(r.exit, 7);
+    }
+
+    #[test]
+    fn ci_sh_merges_env_into_inherited() {
+        // SAFETY: setting an env var in a single-threaded test process.
+        unsafe {
+            std::env::set_var("CI_SH_INHERITED_TEST", "from-parent");
+        }
+        let r = run_sh_via_job(
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push]
+  (fn [_]
+    (ci.sh "echo $CI_SH_INHERITED_TEST $CI_SH_OVERRIDE_TEST"
+           {:env {:CI_SH_OVERRIDE_TEST "from-opts"}})))"#,
+        );
+        assert_eq!(r.exit, 0);
+        assert_eq!(r.stdout, "from-parent from-opts\n");
+    }
+
+    #[test]
+    fn ci_sh_honors_cwd() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        // Resolve symlinks (macOS /tmp → /private/tmp) so the assertion holds.
+        let canonical = fs_err::canonicalize(dir.path()).expect("canonicalize");
+        let source = format!(
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh "pwd" {{:cwd "{}"}})))"#,
+            canonical.display()
+        );
+        let r = run_sh_via_job(&source);
+        assert_eq!(r.exit, 0);
+        assert_eq!(r.stdout.trim(), canonical.to_string_lossy());
+    }
+
+    #[test]
+    fn ci_sh_rejects_unknown_opt_key() {
+        let f = fennel();
+        let pipeline = load(
+            &f,
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh "echo hi" {:cwdir "/tmp"})))"#,
+            "ci.fnl",
+            "ci.fnl",
+            HashMap::new(),
+        )
+        .expect("load should succeed");
+        let err = pipeline.jobs()[0]
+            .run_fn
+            .call::<mlua::Value>(())
+            .unwrap_err();
+        let msg = err.to_string();
+        assert!(
+            msg.contains("unknown field") && msg.contains("cwdir"),
+            "expected unknown-field error mentioning the typo, got: {msg}"
+        );
+    }
+
+    #[test]
+    fn ci_sh_rejects_empty_argv() {
+        let f = fennel();
+        let pipeline = load(
+            &f,
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh [])))"#,
+            "ci.fnl",
+            "ci.fnl",
+            HashMap::new(),
+        )
+        .expect("load should succeed");
+        let err = pipeline.jobs()[0]
+            .run_fn
+            .call::<mlua::Value>(())
+            .unwrap_err();
+        let msg = err.to_string();
+        assert!(
+            msg.contains("empty"),
+            "expected empty-argv error, got: {msg}"
+        );
+    }
+
     #[test]
     fn validate_rejects_unreachable_jobs() {
         // An orphan with non-empty self-input passes pre-graph rules
diff --git a/src/fennel.rs b/src/fennel.rs
index 3b9e20d..04b12dd 100644
--- a/src/fennel.rs
+++ b/src/fennel.rs
@@ -72,6 +72,13 @@ impl Fennel {
         Ok(Self { lua })
     }
 
+    /// Borrow the underlying Lua VM. Useful for callers that need to
+    /// `to_value` / `from_value` against the same VM the Fennel script
+    /// ran in.
+    pub fn lua(&self) -> &Lua {
+        &self.lua
+    }
+
     /// Compile and evaluate a Fennel source string, returning the raw
     /// Lua value.
     ///