Real subprocess execution for a template's sh_before:/sh_after:
frontmatter fields (GgenIgniter.Frontmatter.sh_before/sh_after) --
the one function this module exists for is run/3.
New module, not folded into GgenIgniter.Actuate: Actuate's own
moduledoc frames its scope as "writes a rendered source string to a
file" -- a shell command is neither a file write nor an EEx/eval
actuation path, and giving it its own module keeps Actuate's three
real actuation paths (write_new_file!/2, write_file!/3,
inject_content!/5) from growing a fourth, semantically unrelated one.
Mix.Tasks.GgenIgniter.Sync and GgenIgniter.Reactors.ReconcileReactor
both call this module directly (two genuinely separate call sites, per
their own moduledocs on sh_before:/sh_after: wiring) rather than
through Actuate.
Scope -- disclosed, intentional, matching sh_before:/sh_after:'s
own scope limitation
This module knows nothing about frontmatter, admission, or compensation.
It is a plain subprocess-execution primitive: given a command string and
a working directory, run it for real and report back the real exit code
and combined stdout+stderr. Mix.Tasks.GgenIgniter.Sync.run_via_reactor/3,
Mix.Tasks.GgenIgniter.Sync.run_pipeline!/3, and
GgenIgniter.Reactors.ReconcileReactor's :render/actuate_one/2 are
where the real --allow-sh fail-closed gate, the per-row/per-target
failure-tolerance policy, and GgenIgniter.Receipt.commands population
live -- see those modules' moduledocs for the full, disclosed
admission-gate/compensation exclusion this module's callers document.
Real subprocess, real timeout
System.cmd("sh", ["-c", cmd], cd: project_dir, stderr_to_stdout: true)
runs the command for real, through a real POSIX shell (sh -c), with
stderr merged into the same output stream Mix.Tasks.GgenIgniter.Doctor's
own System.cmd/3 call sites already merge (check_qlever_reachable/2
et al.) -- same convention, not a new one.
No existing System.cmd/3 call site in this repo (ReconcileReactor's
own :verify mix compile subprocess, doctor.ex's three checks) has
ever needed a timeout -- they all run to completion unbounded. A
template-authored sh_before:/sh_after: command is different: it is
arbitrary, untrusted-relative-to-this-pipeline shell text (the same real
trust boundary a template's own EEx body already is -- see
GgenIgniter.Actuate.eval_code!/2's moduledoc for the precedent), and an
unbounded hang here would hang the entire sync/reconciliation run with
no way to interrupt just this one step. run/3 wraps the real
System.cmd/3 call in a real Task.async/1 + Task.yield/2/
Task.shutdown/2 timeout (the standard Elixir Task idiom for a
boundable blocking call), defaulting to 60000ms
(timeout_ms/0), overridable via opts[:timeout_ms].
Summary
Types
{:ok, combined_output} on a real zero exit; {:error, {:sh_exit, code, combined_output}} on a real nonzero exit; {:error, :sh_timeout} when
the command did not finish within opts[:timeout_ms] (the process is
killed via Task.shutdown/2's real :brutal_kill, not left running).
Types
@type result() :: {:ok, String.t()} | {:error, {:sh_exit, integer() | :crashed, String.t()}} | {:error, :sh_timeout}
{:ok, combined_output} on a real zero exit; {:error, {:sh_exit, code, combined_output}} on a real nonzero exit; {:error, :sh_timeout} when
the command did not finish within opts[:timeout_ms] (the process is
killed via Task.shutdown/2's real :brutal_kill, not left running).
Functions
@spec default_timeout_ms() :: pos_integer()
The default timeout (ms) run/3 uses when opts[:timeout_ms] is not given.
Runs cmd for real via sh -c, cd:'d into project_dir, with a real
timeout (opts[:timeout_ms], default 60000ms).
Examples
iex> {:ok, output} = GgenIgniter.ShellHook.run("echo hi", File.cwd!())
iex> String.trim(output)
"hi"
iex> {:error, {:sh_exit, 1, _output}} = GgenIgniter.ShellHook.run("exit 1", File.cwd!())
iex> GgenIgniter.ShellHook.run("sleep 5", File.cwd!(), timeout_ms: 50)
{:error, :sh_timeout}