Packages

Librería base para ejecución de comandos del sistema y gestión de tareas asíncronas con resultados estructurados.

Retired package: Release invalid - Versión publicada por error

Current section

Files

Jump to
argos lib command.ex
Raw

lib/command.ex

defmodule Argos.Command do
@moduledoc """
Módulo para ejecución de comandos del sistema.
Proporciona funciones para ejecutar comandos del sistema con diferentes modos
(normal, raw, silent, interactive, sudo) y con soporte para circuit breaker.
## Características
- Ejecución de comandos con resultados estructurados (`CommandResult`)
- Validación de inputs antes de ejecutar
- Soporte para circuit breaker para prevenir ejecuciones cuando hay fallos
- Integración con asdf para versiones específicas de herramientas
- Manejo de procesos (kill por nombre)
- Logging estructurado con Aurora
## Ejemplos
# Ejecución básica
result = Argos.Command.exec("ls -la")
# Con circuit breaker
result = Argos.Command.exec("risky_command", circuit_breaker: true)
# Con asdf
result = Argos.Command.asdf_exec("mix test", "/path/to/project")
"""
require Logger
alias Argos.Command.CircuitBreaker
alias Argos.{CommandResult, Utils, Validation}
alias Aurora.{Color, Effects, Format}
alias Aurora.Structs.{ChunkText, FormatInfo}
@shell_timeout Application.compile_env(:argos, :shell_timeout, 30_000)
@shell Application.compile_env(:argos, :shell, "/bin/zsh")
@pgrep_delay 2_000
@doc """
Ejecuta un comando de manera normal.
## Validaciones
- El comando no puede ser nil o vacío
- Si es string, debe tener contenido después de trim
- Si es lista, debe tener al menos un elemento
## Ejemplos
iex> Argos.Command.exec("echo test")
%Argos.CommandResult{success?: true, ...}
iex> Argos.Command.exec("")
%Argos.CommandResult{success?: false, error: "Command cannot be empty"}
"""
@spec exec(String.t() | [String.t()], keyword()) :: CommandResult.t()
def exec(command, opts \\ []) do
case Validation.validate_command(command) do
:ok ->
opts = Keyword.put_new(opts, :stderr_to_stdout, true)
if Keyword.get(opts, :circuit_breaker, false) do
execute_with_circuit_breaker(command, opts)
else
__exec__(:normal, command, opts)
end
{:error, reason} ->
CommandResult.failure(
to_string(command),
[],
reason,
1,
0,
reason
)
end
end
defp execute_with_circuit_breaker(command, opts) do
key = Keyword.get(opts, :circuit_breaker_key, :default)
case CircuitBreaker.call(key, fn -> __exec__(:normal, command, opts) end) do
{:ok, result} ->
result
{:error, :circuit_open} ->
CommandResult.failure(
to_string(command),
[],
"Circuit breaker is open",
1,
0,
"Circuit breaker is open"
)
{:error, reason} ->
CircuitBreaker.failure(key)
CommandResult.failure(
to_string(command),
[],
inspect(reason),
1,
0,
inspect(reason)
)
end
end
@doc """
Ejecuta un comando y devuelve la salida cruda.
"""
@spec exec_raw(String.t() | [String.t()], keyword()) :: {String.t(), integer()}
def exec_raw(command, opts \\ []) do
opts = Keyword.put_new(opts, :stderr_to_stdout, true)
__exec__(:raw, command, opts)
end
@doc """
Ejecuta un comando de manera silenciosa.
"""
@spec exec_silent(String.t() | [String.t()], keyword()) :: integer()
def exec_silent(command, opts \\ []) do
opts = Keyword.put_new(opts, :stderr_to_stdout, true)
__exec__(:silent, command, opts)
end
@doc """
Ejecuta un comando de manera interactiva.
"""
@spec exec_interactive(String.t() | [String.t()], keyword()) :: CommandResult.t()
def exec_interactive(command, opts \\ []) do
opts = Keyword.put_new(opts, :interactive, true)
__exec__(:interactive, command, opts)
end
@doc """
Ejecuta un comando con sudo.
"""
@spec exec_sudo(String.t() | [String.t()], keyword()) :: CommandResult.t()
def exec_sudo(command, opts \\ []) do
opts = Keyword.put_new(opts, :stderr_to_stdout, true)
__exec__(:sudo, command, opts)
end
defp __exec__(:raw, command, opts) do
case command do
cmd_list when is_list(cmd_list) and length(cmd_list) > 0 ->
[cmd_head | cmd_tail] = cmd_list
System.cmd(cmd_head, cmd_tail, opts)
cmd_string when is_binary(cmd_string) ->
System.cmd(@shell, ["-c", cmd_string], opts)
_ ->
System.cmd(@shell, ["-c", to_string(command)], opts)
end
end
defp __exec__(:normal, command, opts) do
{halt?, opts} = Keyword.pop(opts, :halt, false)
{silent?, opts} = Keyword.pop(opts, :silent, false)
start_time = System.monotonic_time(:millisecond)
{output, exit_code} = __exec__(:raw, command, opts)
duration = System.monotonic_time(:millisecond) - start_time
result = %CommandResult{
command: @shell,
args: ["-c", command],
output: output,
exit_code: exit_code,
duration: duration,
success?: exit_code == 0,
error: nil
}
unless silent? do
__log_result__(command, result, get_caller_metadata())
end
__handle_halt__(result, halt?)
result
end
defp __exec__(:silent, command, opts) do
result = __exec__(:normal, "#{command} > /dev/null 2>&1", Keyword.put(opts, :silent, true))
result.exit_code
end
defp __exec__(:interactive, command, opts) do
halt? = Keyword.get(opts, :halt, false)
silent? = Keyword.get(opts, :silent, false)
start_time = System.monotonic_time(:millisecond)
case System.find_executable("script") do
nil ->
port =
Port.open({:spawn_executable, System.find_executable(@shell)}, [
:binary,
{:args, ["-c", command]},
:exit_status,
:stderr_to_stdout
])
result = __collect_output__(command, port, "", start_time, silent?)
__handle_halt__(result, halt?)
script_path ->
args = ["-q", "/dev/null", @shell, "-c", command]
port =
Port.open({:spawn_executable, script_path}, [
:binary,
:stream,
:exit_status,
:stderr_to_stdout,
{:args, args}
])
command
|> __collect_output__(port, "", start_time, silent?)
|> __handle_halt__(halt?)
end
end
defp __exec__(:sudo, command, opts) do
interactive? = Keyword.get(opts, :interactive, false)
halt? = Keyword.get(opts, :halt, false)
silent? = Keyword.get(opts, :silent, false)
start_time = System.monotonic_time(:millisecond)
unless silent? do
Logger.warning("SUDO command execution attempted")
end
executable = System.find_executable("sudo")
if executable do
askpass_path = System.find_executable("ssh-askpass") || "/usr/bin/ssh-askpass"
askpass_available? = File.exists?(askpass_path)
args = get_sudo_args(command, interactive? and askpass_available?)
port_opts = [
:binary,
:stream,
:exit_status,
:stderr_to_stdout,
{:args, args}
]
final_port_opts =
if askpass_available? do
[{:env, [{"SUDO_ASKPASS", askpass_path}]} | port_opts]
else
port_opts
end
port = Port.open({:spawn_executable, executable}, final_port_opts)
command
|> __collect_output__(port, "", start_time, silent?)
|> __handle_halt__(halt?)
else
duration = System.monotonic_time(:millisecond) - start_time
result = %CommandResult{
command: command,
args: [],
output: "sudo command not found",
exit_code: 127,
duration: duration,
success?: false,
error: "sudo command not found"
}
unless silent? do
__log_result__(command, result, get_caller_metadata())
end
result
end
end
defp get_caller_metadata do
case Process.info(self(), :current_stacktrace) do
{:current_stacktrace, stacktrace} ->
caller_frame =
Enum.find(stacktrace, fn
{Argos.Command, _, _, _} -> false
{_, _, _, _} -> true
_ -> false
end) || List.first(stacktrace)
case caller_frame do
{module, function, arity, [file: file, line: line]} ->
%{
module: module,
function: "#{function}/#{arity}",
file: file,
line: line
}
{module, function, arity, _} ->
%{
module: module,
function: "#{function}/#{arity}",
file: "unknown",
line: 0
}
_ ->
%{module: "unknown", function: "unknown/0", file: "unknown", line: 0}
end
_ ->
%{module: "unknown", function: "unknown/0", file: "unknown", line: 0}
end
end
defp __log_result__(_command, %CommandResult{success?: true} = _result, _caller_metadata) do
:ok
end
defp __log_result__(command, %CommandResult{} = result, caller_metadata) do
{term_rows, _} = Utils.get_terminal_size()
error_row = div(term_rows, 2) + 1
metadata =
[
command: command,
exit_code: result.exit_code,
duration: result.duration,
success?: result.success?,
output_length: String.length(result.output || ""),
position: error_row
] ++ Map.to_list(caller_metadata)
__log_error_tui__(command, result, metadata)
end
defp __log_error_tui__(command, result, metadata) do
{term_rows, term_cols} = Utils.get_terminal_size()
error_row = div(term_rows, 2) + 1
IO.write("\e[#{error_row};1H\e[2K")
error_msg =
"Command failed: #{command} (exit: #{result.exit_code}, duration: #{result.duration}ms)"
max_length = max(term_cols - 10, 20)
display_msg =
if String.length(error_msg) > max_length do
String.slice(error_msg, 0, max_length) <> "..."
else
error_msg
end
safe_render = fn ->
try do
error_chunk = %ChunkText{
text: display_msg,
color: Color.to_color_info(:error),
effects: Effects.to_effect_info([:bold])
}
format_info = %FormatInfo{
chunks: [error_chunk],
align: :left,
add_line: :none
}
IO.write("\e[#{error_row};1H")
IO.write(Format.format(format_info))
rescue
_ ->
IO.puts(display_msg)
catch
_, _ ->
IO.puts(display_msg)
end
end
safe_render.()
std_md = Keyword.take(metadata, [:module, :function, :file, :line, :application, :pid])
Logger.error("Command failed: #{command}", std_md)
end
def get_interactive_args(command, false), do: ["-A", @shell, "-c", command]
def get_interactive_args(command, true) do
case System.find_executable("script") do
nil ->
["-A", @shell, "-c", command]
script_path ->
[
"-A",
script_path,
"-q",
"/dev/null",
@shell,
"-c",
command
]
end
end
@dialyzer {:no_return, halt: 0}
@dialyzer {:no_return, halt: 1}
def halt(code \\ 0) do
System.halt(code)
end
def __handle_halt__(%CommandResult{exit_code: code, output: _msg}, true)
when code not in [0, 1] do
Logger.error("Command failed, halting system")
halt()
end
def __handle_halt__(result, _), do: result
defp __collect_output__(command, port, acc, start_time, silent?) when is_integer(start_time) do
actual_start_time = start_time
receive do
{^port, {:data, data}} ->
__collect_output__(command, port, acc <> data, actual_start_time, silent?)
{^port, {:exit_status, code}} ->
duration = System.monotonic_time(:millisecond) - actual_start_time
result = %CommandResult{
command: command,
args: [],
output: acc,
exit_code: code,
duration: duration,
success?: code == 0,
error: nil
}
unless silent? do
__log_result__(command, result, get_caller_metadata())
end
result
after
@shell_timeout ->
if is_port(port), do: Port.close(port)
result = %CommandResult{
command: command,
args: [],
output: acc,
exit_code: 1,
duration: System.monotonic_time(:millisecond) - actual_start_time,
success?: false,
error: "Timeout after #{@shell_timeout}ms"
}
unless silent? do
__log_result__(command, result, get_caller_metadata())
end
result
end
end
def process_response(code, opts \\ []) do
message =
code
|> get_message(opts)
|> normalize_response()
%{
code: code,
message: message,
type: get_message_type(code)
}
end
defp normalize_response(message, default_value \\ "") do
(message || default_value)
|> to_string()
|> String.trim()
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
end
defp get_message(0, opts), do: Keyword.get(opts, :success_message, "")
defp get_message(1, opts), do: Keyword.get(opts, :warning_message, "")
defp get_message(_, opts), do: Keyword.get(opts, :error_message, "")
defp get_message_type(0), do: :success
defp get_message_type(1), do: :warning
defp get_message_type(_), do: :error
@doc """
Mata un proceso por nombre.
## Validaciones
- El nombre debe ser un string no vacío
- Longitud máxima: 255 caracteres
- Solo caracteres alfanuméricos, puntos, guiones y guiones bajos
## Ejemplos
iex> Argos.Command.kill_process("my_process")
%Argos.CommandResult{success?: true, ...}
iex> Argos.Command.kill_process("")
%Argos.CommandResult{success?: false, error: "Process name cannot be empty"}
"""
@spec kill_process(String.t()) :: CommandResult.t()
def kill_process(process_name) when is_binary(process_name) do
case Validation.validate_process_name(process_name) do
:ok ->
escaped_name = Validation.escape_process_name(process_name)
exec("pgrep -f '#{escaped_name}' | head -10 | xargs kill -TERM", silent: true)
{:error, reason} ->
CommandResult.failure(
"kill",
[process_name],
"Invalid process name: #{reason}",
1,
0,
reason
)
end
end
@spec kill_process(term()) :: CommandResult.t()
def kill_process(_invalid) do
CommandResult.failure(
"kill",
[],
"Process name must be a string",
1,
0,
"Process name must be a string"
)
end
@spec kill_processes_by_name([String.t()]) :: [CommandResult.t()]
def kill_processes_by_name(process_names) when is_list(process_names) do
Logger.info("Killing processes")
Enum.map(process_names, fn process_name ->
case kill_process_robust(process_name) do
:ok -> {process_name, :killed}
:not_found -> {process_name, :not_found}
{:error, reason} -> {process_name, {:error, reason}}
end
end)
end
defp kill_process_robust(process_name) do
with {:kill_term, %CommandResult{exit_code: 0}} <-
do_kill_term(process_name),
{:pgrep, %CommandResult{exit_code: pgrep_code}} <-
do_pgrep(process_name),
{:pkill, %CommandResult{exit_code: 0}} <- do_pkill(process_name, pgrep_code) do
:ok
else
{:kill_term, %CommandResult{exit_code: 1}} ->
:not_found
{:kill_term, %CommandResult{output: _error, exit_code: _code}} ->
Logger.error("Error killing process")
{:error, :kill_term_command_failed}
{:pkill, %CommandResult{exit_code: exit_code}} when exit_code != 0 ->
{:error, :pkill_failed}
end
end
defp do_kill_term(process_name) do
{output, exit_code} = System.cmd("pkill", ["-TERM", process_name])
{:kill_term,
%CommandResult{
command: "pkill",
args: ["-TERM", process_name],
output: output,
exit_code: exit_code,
duration: 0,
success?: exit_code == 0
}}
end
defp do_pgrep(process_name) do
Process.sleep(@pgrep_delay)
{output, exit_code} = System.cmd("pgrep", [process_name])
{:pgrep,
%CommandResult{
command: "pgrep",
args: [process_name],
output: output,
exit_code: exit_code,
duration: 0,
success?: exit_code == 0
}}
end
defp do_pkill(_process_name, 1), do: {:pkill, %CommandResult{exit_code: 0}}
defp do_pkill(process_name, 0) do
{output, exit_code} = System.cmd("pkill", ["-KILL", process_name])
{:pkill,
%CommandResult{
command: "pkill",
args: ["-KILL", process_name],
output: output,
exit_code: exit_code,
duration: 0,
success?: exit_code == 0
}}
end
defp get_sudo_args(command, false) do
["-n", @shell, "-c", command]
end
defp get_sudo_args(command, true) do
["-n", "-A", @shell, "-c", command]
end
@doc """
Ejecuta un comando usando las versiones de Elixir/Erlang específicas del proyecto
usando asdf, leyendo el archivo .tool-versions del directorio especificado.
## Parameters
* `command` - Comando a ejecutar
* `project_path` - Ruta del proyecto que contiene el .tool-versions
* `opts` - Opciones adicionales (mismas que `exec/2`)
## Examples
Argos.Command.asdf_exec("mix test", "/path/to/project")
"""
@spec asdf_exec(String.t(), String.t(), keyword()) :: CommandResult.t()
def asdf_exec(command, project_path, opts \\ []) do
tool_versions_path = Path.join(project_path, ".tool-versions")
if File.exists?(tool_versions_path) do
_tool_versions =
tool_versions_path
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.reduce(%{}, &parse_tool_version/2)
asdf_command = "(cd #{project_path} && asdf exec #{command})"
env_opts = Keyword.get(opts, :env, [])
env_list = normalize_env_opts_with_asdf(env_opts)
opts = Keyword.put(opts, :env, env_list)
exec(asdf_command, opts)
else
fallback_command = "cd #{project_path} && #{command}"
env_opts = Keyword.get(opts, :env, [])
env_list = normalize_env_opts_with_asdf(env_opts)
opts = Keyword.put(opts, :env, env_list)
exec(fallback_command, opts)
end
end
defp parse_tool_version(line, acc) do
case String.split(line, " ", trim: true) do
[tool, version] -> Map.put(acc, tool, version)
_ -> acc
end
end
defp build_asdf_path do
home = System.get_env("HOME") || "~"
asdf_shims = Path.join(home, ".asdf/shims")
asdf_bin = Path.join(home, ".asdf/bin")
current_path = System.get_env("PATH") || ""
"#{asdf_shims}:#{asdf_bin}:#{current_path}"
end
defp normalize_env_opts_with_asdf(env_opts, additional_path \\ nil) do
path_value = additional_path || build_asdf_path()
normalized =
env_opts
|> Enum.map(fn
{k, v} when is_atom(k) -> {Atom.to_string(k), to_string(v)}
{k, v} when is_binary(k) -> {k, to_string(v)}
other -> other
end)
|> Enum.reject(fn {k, _v} -> k == "PATH" end)
normalized ++ [{"PATH", path_value}]
end
end