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