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
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.
///