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 cli executor.ex
Raw

lib/cli/executor.ex

defmodule Argos.CLI.Executor do
@moduledoc """
Módulo para ejecutar comandos del CLI.
Maneja la ejecución paralela de comandos del sistema, incluyendo:
- Creación de scripts temporales
- Gestión de variables de entorno (incluyendo asdf)
- Ejecución mediante Ports
- Captura de salida en tiempo real
- Limpieza de archivos temporales
## Características
- Ejecución paralela usando Tasks de Elixir
- Preservación de códigos ANSI
- Soporte para carga de configuración del shell
- Integración con asdf para versiones específicas
"""
alias Argos.{CLI.Config, Command, CommandResult}
@default_command_timeout 300_000
@doc """
Ejecuta múltiples comandos en paralelo.
"""
@spec run_commands_parallel([String.t()], keyword()) :: no_return()
def run_commands_parallel(commands, opts \\ []) do
tmp_dir = create_session_temp_dir()
opts_with_tmp = Keyword.put(opts, :tmp_dir, tmp_dir)
tasks =
Enum.with_index(commands, 1)
|> Enum.map(fn {cmd, index} ->
Task.async(fn ->
execute_command(cmd, Keyword.put(opts_with_tmp, :script_index, index))
end)
end)
results = Enum.map(tasks, &Task.await(&1, :infinity))
cleanup_session_temp_dir(tmp_dir)
exit_code = if Enum.all?(results, & &1.success?), do: 0, else: 1
System.halt(exit_code)
end
@doc """
Ejecuta un comando individual.
"""
@spec execute_command(String.t(), keyword()) :: CommandResult.t()
def execute_command(command, opts \\ []) do
if Keyword.get(opts, :asdf_local, false) do
project_path = extract_project_path_from_command(command) || File.cwd!()
Command.asdf_exec(command, project_path, opts)
else
execute_command_normal(command, opts)
end
end
defp execute_command_normal(command, opts) do
script_path = create_temp_script(command, opts)
shell = Config.get_shell()
shell_executable = Config.get_shell_executable()
should_load_config = Config.should_load_config(opts)
shell_args = build_shell_args_for_script(script_path, shell, should_load_config)
current_dir = File.cwd!()
env_vars = Config.build_env_vars_with_asdf()
port = start_port(shell_executable, shell_args, current_dir, env_vars)
{output, exit_code, duration} = execute_and_collect_output(port)
cleanup_script_if_needed(script_path, opts)
ensure_newline(output)
build_command_result(shell, command, output, exit_code, duration)
end
defp create_session_temp_dir do
timestamp = System.system_time(:second)
random = :erlang.unique_integer([:positive, :monotonic])
tmp_dir = Path.join(System.tmp_dir!(), "argos_#{timestamp}_#{random}")
File.mkdir_p!(tmp_dir)
tmp_dir
end
defp cleanup_session_temp_dir(tmp_dir) do
if File.exists?(tmp_dir) do
File.rm_rf!(tmp_dir)
end
rescue
_ -> :ok
end
defp create_temp_script(command, opts) do
tmp_dir = Keyword.get(opts, :tmp_dir) || create_session_temp_dir()
script_index = Keyword.get(opts, :script_index, 1)
script_name = "argos_#{String.pad_leading(to_string(script_index), 3, "0")}.sh"
script_path = Path.join(tmp_dir, script_name)
shell = Config.get_shell()
shell_path = Config.get_shell_executable()
should_load_config = Config.should_load_config(opts)
script_content = build_script_content(command, shell, should_load_config, shell_path)
File.write!(script_path, script_content)
File.chmod!(script_path, 0o755)
script_path
end
defp build_script_content(command, shell, should_load_config, shell_path) do
shebang = "#!#{shell_path}\n"
final_command = escape_command_for_shell(command)
config_section =
if should_load_config do
config_file = Config.get_shell_config_file(shell)
env_vars_to_preserve = get_env_vars_to_preserve()
preserve_env_no_path =
env_vars_to_preserve
|> Enum.reject(&(&1 == "PATH"))
|> Enum.map(&build_env_preserve_line/1)
|> Enum.reject(&is_nil/1)
|> Enum.join("\n")
restore_env_no_path =
env_vars_to_preserve
|> Enum.reject(&(&1 == "PATH"))
|> Enum.map(&build_env_restore_line/1)
|> Enum.reject(&is_nil/1)
|> Enum.join("\n")
asdf_setup = build_asdf_setup(shell)
build_config_section(shell, config_file, preserve_env_no_path, restore_env_no_path, asdf_setup, final_command)
else
"#{final_command}\n"
end
shebang <> config_section
end
defp get_env_vars_to_preserve do
[
"PATH",
"MIX_ENV",
"MIX_TARGET",
"ERL_AFLAGS",
"ERL_FLAGS",
"ELIXIR_ERL_OPTIONS",
"ELIXIR_ERL_FLAGS",
"KERL_BUILD_BACKEND",
"ASDF_ERLANG_VERSION",
"ASDF_ELIXIR_VERSION",
"ASDF_DATA_DIR",
"ASDF_INSTALL_PATH",
"ASDF_INSTALL_TYPE",
"ASDF_DIR",
"ASDF_CONFIG_FILE",
"ROOTDIR",
"EMU"
]
end
defp build_env_preserve_line(var) do
case System.get_env(var) do
nil -> nil
value -> "ORIG_#{var}='#{escape_single_quotes(value)}'; export ORIG_#{var}"
end
end
defp build_env_restore_line(var) do
case System.get_env(var) do
nil -> nil
value -> "#{var}='#{escape_single_quotes(value)}'; export #{var}"
end
end
defp escape_single_quotes(str) do
String.replace(str, "'", "'\\''")
end
defp escape_command_for_shell(command) do
command
end
defp build_shell_args_for_script(script_path, shell, _should_load_config) do
if String.contains?(to_string(shell), "zsh") do
["-i", script_path]
else
[script_path]
end
end
defp build_asdf_setup(shell) do
if String.contains?(to_string(shell), "zsh") do
[
"autoload -U add-zsh-hook; ",
"asdf_force_detect() { ",
" if command -v asdf >/dev/null 2>&1; then ",
" local asdf_dir=\"${ASDF_DIR:-$HOME/.asdf}\"; ",
" if [[ -d \"$asdf_dir\" ]] && [[ -f \"$asdf_dir/lib/commands/current.bash\" || -f \"$asdf_dir/lib/commands/current.sh\" ]]; then ",
" export PATH=\"$asdf_dir/shims:$asdf_dir/bin:$PATH\"; ",
" if [[ -f .tool-versions ]]; then ",
" source \"$asdf_dir/lib/utils.bash\" 2>/dev/null || source \"$asdf_dir/lib/utils.sh\" 2>/dev/null || true; ",
" while IFS= read -r line || [[ -n \"$line\" ]]; do ",
" [[ -z \"$line\" ]] || [[ \"$line\" =~ ^[[:space:]]*# ]] && continue; ",
" local tool=$(echo \"$line\" | awk '{print $1}'); ",
" local version=$(echo \"$line\" | awk '{print $2}'); ",
" if [[ -n \"$tool\" && -n \"$version\" ]]; then ",
" local tool_path=\"$asdf_dir/installs/$tool/$version/bin\"; ",
" if [[ -d \"$tool_path\" ]]; then ",
" export PATH=\"$tool_path:$PATH\"; ",
" fi; ",
" fi; ",
" done < .tool-versions; ",
" fi; ",
" fi; ",
" fi; ",
"}; ",
"add-zsh-hook chpwd asdf_force_detect; ",
"asdf_force_detect; "
]
|> Enum.join()
else
""
end
end
defp build_config_section(shell, config_file, preserve_env_no_path, restore_env_no_path, asdf_setup, final_command) do
is_zsh = String.contains?(to_string(shell), "zsh")
has_env_vars = preserve_env_no_path != "" && restore_env_no_path != ""
cond do
has_env_vars && is_zsh ->
"# Preservar variables de entorno\n#{preserve_env_no_path}\n" <>
"# Cargar configuración del shell\nsource #{config_file} 2>/dev/null\n" <>
"# Restaurar variables de entorno\n#{restore_env_no_path}\n" <>
"# Habilitar aliases\nsetopt aliases\n" <>
"# Setup asdf\n#{asdf_setup}\n" <>
"# Ejecutar comando\n#{final_command}\n"
has_env_vars ->
"# Preservar variables de entorno\n#{preserve_env_no_path}\n" <>
"# Cargar configuración del shell\nsource #{config_file} 2>/dev/null\n" <>
"# Restaurar variables de entorno\n#{restore_env_no_path}\n" <>
"# Ejecutar comando\n#{final_command}\n"
is_zsh ->
"# Cargar configuración del shell\nsource #{config_file} 2>/dev/null\n" <>
"# Habilitar aliases\nsetopt aliases\n" <>
"# Setup asdf\n#{asdf_setup}\n" <>
"# Ejecutar comando\n#{final_command}\n"
true ->
"# Cargar configuración del shell\nsource #{config_file} 2>/dev/null\n" <>
"# Ejecutar comando\n#{final_command}\n"
end
end
defp cleanup_script_if_needed(script_path, opts) do
unless Keyword.has_key?(opts, :tmp_dir) do
cleanup_temp_script(script_path)
end
end
defp cleanup_temp_script(script_path) do
File.rm(script_path)
tmp_dir = Path.dirname(script_path)
if File.exists?(tmp_dir) do
File.rmdir(tmp_dir)
end
rescue
_ -> :ok
end
defp start_port(shell_executable, shell_args, current_dir, env_vars) do
Port.open({:spawn_executable, shell_executable}, [
:binary,
{:args, shell_args},
{:cd, current_dir},
{:env, env_vars},
:exit_status,
:stderr_to_stdout
])
end
defp execute_and_collect_output(port) do
start_time = System.monotonic_time(:millisecond)
{output, exit_code} = collect_port_output(port, "")
duration = System.monotonic_time(:millisecond) - start_time
{output, exit_code, duration}
end
defp collect_port_output(port, acc) do
receive do
{^port, {:data, data}} ->
IO.write(data)
collect_port_output(port, acc <> data)
{^port, {:exit_status, code}} ->
{acc, code}
_other ->
collect_port_output(port, acc)
after
@default_command_timeout ->
if Port.info(port) != nil do
Port.close(port)
end
{acc, 1}
end
end
defp ensure_newline(output) do
if output != "" && not String.ends_with?(output, ["\n", "\r\n"]) do
IO.puts("")
end
end
defp build_command_result(shell, command, output, exit_code, duration) do
%CommandResult{
command: shell,
args: ["-c", command],
output: output,
exit_code: exit_code,
duration: duration,
success?: exit_code == 0,
error: nil
}
end
defp extract_project_path_from_command(command) do
case Regex.run(~r/cd\s+([^\s;&&|]+)/, command) do
[_, path] ->
expanded_path = expand_path(path)
if File.exists?(expanded_path), do: expanded_path, else: nil
_ ->
detect_alias_path(command)
end
end
defp expand_path(path) do
home = System.get_env("HOME") || "~"
expanded_home = String.replace(path, "~", home)
Regex.replace(~r/\$\{?(\w+)\}?/, expanded_home, fn _match, var ->
System.get_env(var) || "${#{var}}"
end)
end
defp detect_alias_path(command) do
alias_patterns = [
{"tdai", "/Users/lorenzo.sanchez/workspace/truedat/back/td-ai"},
{"tdaudit", "/Users/lorenzo.sanchez/workspace/truedat/back/td-audit"},
{"tdauth", "/Users/lorenzo.sanchez/workspace/truedat/back/td-auth"},
{"tdbg", "/Users/lorenzo.sanchez/workspace/truedat/back/td-bg"},
{"tddd", "/Users/lorenzo.sanchez/workspace/truedat/back/td-dd"},
{"tddf", "/Users/lorenzo.sanchez/workspace/truedat/back/td-df"},
{"tdi18n", "/Users/lorenzo.sanchez/workspace/truedat/back/td-i18n"},
{"tdie", "/Users/lorenzo.sanchez/workspace/truedat/back/td-ie"},
{"tdlm", "/Users/lorenzo.sanchez/workspace/truedat/back/td-lm"},
{"tdqx", "/Users/lorenzo.sanchez/workspace/truedat/back/td-qx"},
{"tdse", "/Users/lorenzo.sanchez/workspace/truedat/back/td-se"}
]
Enum.find_value(alias_patterns, fn {alias_name, path} ->
if String.contains?(command, alias_name) and File.exists?(path), do: path
end)
end
end