Arrea.Command (Arrea v2.2.0)

Copy Markdown View Source

Synchronous command execution with version manager support.

Provides a clean interface to run shell commands with validation, shell selection, automatic detection of configuration files, asdf/mise integration, and structured result parsing.

For parallel execution, use Arrea.Parallel or Arrea.Leader.

Shell resolution

The shell is resolved with the following priority (lowest to highest):

  1. Config.get(:shell) — consumer project or runtime config
  2. $SHELL environment variable
  3. Current user login shell (/etc/passwd)
  4. :shell option passed to execute/2highest priority
  5. "sh" as a last resort if none of the above is valid

Both names ("zsh") and paths ("/bin/zsh") are accepted. Names are resolved to paths via System.find_executable/1. If the configured shell does not exist, it falls back to the system default ($SHELL or "sh").

Based on the detected shell, its configuration file is forced to load (e.g. ~/.bashrc for bash, ~/.zshrc for zsh, ~/.config/fish/config.fish for fish).

Real timeout

The timeout is enforced during execution: if the command does not finish within timeout ms, the execution process is cancelled. The underlying OS process receives SIGKILL when the Erlang port is closed as its owner dies.

asdf/mise version management

Runtime versions can be forced via two mechanisms:

  • asdfasdf_<language> option: emits export ASDF_<LANG>_VERSION=<version> before the command. Works with both asdf and mise.

  • misemise_<language> option: wraps the command with mise exec <language>@<version> -- <command>.

Examples

iex> Arrea.Command.execute("echo hello")
{:ok, %{stdout: "hello\n", exit_code: 0, duration_ms: 3}}

iex> Arrea.Command.execute("mix test", asdf_elixir: "1.18.0")
{:ok, %{stdout: "...", exit_code: 0, duration_ms: 1200}}

iex> Arrea.Command.execute("node -v", mise_node: "20.0.0")
{:ok, %{stdout: "v20.0.0\n", exit_code: 0, duration_ms: 80}}

iex> Arrea.Command.execute("sleep 60", timeout: 500)
{:error, :timeout}

Summary

Functions

Extrae los argumentos mise_<lang> de las opciones y los formatea como "lang@version" para el comando mise exec.

Returns true if the given command exists in the system PATH.

Executes a command string synchronously with optional configuration.

Executes a command with a language version managed by ASDF.

Parses a raw result map into a structured form.

Resolves the shell to use, by priority (lowest to highest)

Resolves the path to the shell configuration file.

Resolves a shell name to its absolute path.

Returns the full path of a command if found in PATH, or nil.

Types

result()

@type result() :: %{
  stdout: String.t(),
  exit_code: non_neg_integer(),
  duration_ms: non_neg_integer()
}

Functions

build_mise_args(opts)

@spec build_mise_args(keyword()) :: [String.t()]

Extrae los argumentos mise_<lang> de las opciones y los formatea como "lang@version" para el comando mise exec.

command_exists?(cmd)

@spec command_exists?(String.t()) :: boolean()

Returns true if the given command exists in the system PATH.

Thin wrapper around System.find_executable/1 exposed publicly so consumers (Apero.Proc, Botica.Batteries.*) don't have to redefine it and can centralise the lookup through Arrea for future observability.

Examples

iex> Arrea.Command.command_exists?("echo")
true

iex> Arrea.Command.command_exists?("definitely_not_a_real_binary_12345")
false

execute(cmd, opts \\ [])

@spec execute(
  String.t(),
  keyword()
) :: {:ok, result()} | {:error, term()}

Executes a command string synchronously with optional configuration.

The command is validated before execution unless :validate is set to false. Invalid or dangerous commands return {:error, reason} without executing anything.

The timeout is real: if the command does not finish within the limit, the execution process is actively cancelled (not post-hoc).

Options

  • :timeout — Maximum execution time in ms (default: 30_000)
  • :cd — Working directory (default: current directory)
  • :shell — Shell to use — takes highest priority over config and env
  • :shell_config — Path to the shell configuration file to load (optional)
  • :env — Additional environment variables as a map (optional)
  • :quiet — If true, suppress stderr capture (default: false)
  • :validate — Run the safety validator before execution (default: true). Set to false for trusted internal callers (e.g. Apero's OS info commands) that would otherwise pay the per-call validation cost or hit false positives.
  • :asdf_elixir — Force Elixir version via asdf/mise
  • asdf_<lang> — Force any language version via asdf/mise
  • mise_<lang> — Force version via mise exec

Returns

  • {:ok, result} — Map with :stdout, :exit_code, :duration_ms
  • {:error, :timeout} — The command was cancelled for exceeding the timeout
  • {:error, reason} — Validation or execution error

execute_with_asdf(cmd, language, version, opts \\ [])

@spec execute_with_asdf(String.t(), atom(), String.t(), keyword()) ::
  {:ok, result()} | {:error, term()}

Executes a command with a language version managed by ASDF.

Convenience wrapper around execute/2 that prepends the asdf shim activation.

Examples

iex> Command.execute_with_asdf("mix test", :elixir, "1.18.0")
{:ok, %{stdout: "...", exit_code: 0, duration_ms: 1200}}

parse_result(result)

@spec parse_result(result()) ::
  {:ok, result()} | {:error, {:exit_code, non_neg_integer()}}

Parses a raw result map into a structured form.

Detects common error patterns and returns tagged results.

resolve_shell(opts \\ [])

@spec resolve_shell(keyword()) :: String.t()

Resolves the shell to use, by priority (lowest to highest):

  1. Config.get(:shell) (project config or Config.set/2)
  2. $SHELL environment variable
  3. User login shell in /etc/passwd
  4. :shell option passed in opts — highest priority
  5. "sh" as a fallback if none is valid

If the resolved shell is a name (e.g. "zsh"), it is looked up in PATH. If not found, it falls back to the system default.

resolve_shell_config(shell)

@spec resolve_shell_config(String.t()) :: String.t() | nil

Resolves the path to the shell configuration file.

Returns the expanded path to the config file (e.g. ~/.zshrc for zsh) or nil if the shell has no known config file.

resolve_shell_path(shell)

@spec resolve_shell_path(String.t()) :: String.t() | nil

Resolves a shell name to its absolute path.

If it is already a path (contains /), it is returned as-is if it exists. If it is just a name, it is looked up in PATH via System.find_executable/1. Returns nil if the executable cannot be found.

which(cmd)

@spec which(String.t()) :: String.t() | nil

Returns the full path of a command if found in PATH, or nil.

Counterpart to the POSIX which(1) command. Wraps System.find_executable/1 for consistency with command_exists?/1.

Examples

iex> Arrea.Command.which("echo")
"/usr/bin/echo"

iex> Arrea.Command.which("not_a_real_binary_12345")
nil