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