defmodule Raxol.Terminal.Script.ScriptServer do @moduledoc """ Unified scripting system for the Raxol terminal emulator. Handles script execution, management, and integration with the terminal. REFACTORED: All try/rescue blocks replaced with functional patterns using Task. """ use Raxol.Core.Behaviours.BaseManager # Raxol.Core.StateManager lives in main raxol; guarded at runtime @compile {:no_warn_undefined, Raxol.Core.StateManager} alias Raxol.Core.Runtime.Log @type script_id :: String.t() @type script_type :: :lua | :python | :javascript | :elixir @type script_state :: %{ id: script_id, name: String.t(), type: script_type, source: String.t(), config: map(), status: :idle | :running | :paused | :error, error: String.t() | nil, output: [String.t()], metadata: map() } def start_script_manager(opts \\ []) do start_link([{:name, __MODULE__} | opts]) end @doc """ Loads a script from a file or string source. """ def load_script(source, type, opts \\ []) do GenServer.call(__MODULE__, {:load_script, source, type, opts}) end @doc """ Unloads a script by its ID. """ def unload_script(script_id) do GenServer.call(__MODULE__, {:unload_script, script_id}) end @doc """ Gets the state of a script. """ def get_script_state(script_id) do GenServer.call(__MODULE__, {:get_script_state, script_id}) end @doc """ Updates a script's configuration. """ def update_script_config(script_id, config) do GenServer.call(__MODULE__, {:update_script_config, script_id, config}) end @doc """ Executes a script with optional arguments. """ def execute_script(script_id, args \\ []) do GenServer.call(__MODULE__, {:execute_script, script_id, args}) end @doc """ Pauses a running script. """ def pause_script(script_id) do GenServer.call(__MODULE__, {:pause_script, script_id}) end @doc """ Resumes a paused script. """ def resume_script(script_id) do GenServer.call(__MODULE__, {:resume_script, script_id}) end @doc """ Stops a running script. """ def stop_script(script_id) do GenServer.call(__MODULE__, {:stop_script, script_id}) end @doc """ Gets the output of a script. """ def get_script_output(script_id) do GenServer.call(__MODULE__, {:get_script_output, script_id}) end @doc """ Gets all loaded scripts. """ def get_scripts(opts \\ []) do GenServer.call(__MODULE__, {:get_scripts, opts}) end @doc """ Exports a script to a file. """ def export_script(script_id, path) do GenServer.call(__MODULE__, {:export_script, script_id, path}) end @doc """ Imports a script from a file. """ def import_script(path, opts \\ []) do GenServer.call(__MODULE__, {:import_script, path, opts}) end # BaseManager Callbacks @impl true def init_manager(opts) do state = %{ scripts: %{}, script_paths: Keyword.get(opts, :script_paths, ["scripts"]), auto_load: Keyword.get(opts, :auto_load, false), max_scripts: Keyword.get(opts, :max_scripts, 100), script_timeout: Keyword.get(opts, :script_timeout, 30_000) } maybe_load_scripts(state.auto_load, state.script_paths) {:ok, state} end @impl true def handle_manager_call({:load_script, source, type, opts}, _from, state) do script_id = generate_script_id() script_state = load_script_state(source, type, opts) case validate_script(script_state) do :ok -> new_state = put_in(state.scripts[script_id], script_state) {:reply, {:ok, script_id}, new_state} {:error, reason} -> {:reply, {:error, reason}, state} end end def handle_manager_call({:unload_script, script_id}, _from, state) do case Map.get(state.scripts, script_id) do nil -> {:reply, {:error, :script_not_found}, state} _script -> new_state = update_in(state.scripts, &Map.delete(&1, script_id)) {:reply, :ok, new_state} end end def handle_manager_call({:get_script_state, script_id}, _from, state) do case Map.get(state.scripts, script_id) do nil -> {:reply, {:error, :script_not_found}, state} script -> {:reply, {:ok, script}, state} end end def handle_manager_call( {:update_script_config, script_id, config}, _from, state ) do case Map.get(state.scripts, script_id) do nil -> {:reply, {:error, :script_not_found}, state} script -> update_script_config_if_valid(config, script, script_id, state) end end def handle_manager_call({:execute_script, script_id, args}, _from, state) do case Map.get(state.scripts, script_id) do nil -> {:reply, {:error, :script_not_found}, state} script -> case do_execute_script(script, args, state.script_timeout) do {:ok, result} -> new_script = %{ script | status: :running, output: [result | script.output] } new_state = put_in(state.scripts[script_id], new_script) {:reply, {:ok, result}, new_state} {:error, reason} -> new_script = %{script | status: :error, error: reason} new_state = put_in(state.scripts[script_id], new_script) {:reply, {:error, reason}, new_state} end end end def handle_manager_call({:pause_script, script_id}, _from, state) do case Map.get(state.scripts, script_id) do nil -> {:reply, {:error, :script_not_found}, state} script -> pause_script_if_running(script.status, script, script_id, state) end end def handle_manager_call({:resume_script, script_id}, _from, state) do case Map.get(state.scripts, script_id) do nil -> {:reply, {:error, :script_not_found}, state} script -> resume_script_if_paused(script.status, script, script_id, state) end end def handle_manager_call({:stop_script, script_id}, _from, state) do case Map.get(state.scripts, script_id) do nil -> {:reply, {:error, :script_not_found}, state} script -> stop_script_if_active(script.status, script, script_id, state) end end def handle_manager_call({:get_script_output, script_id}, _from, state) do case Map.get(state.scripts, script_id) do nil -> {:reply, {:error, :script_not_found}, state} %{output: [latest | _]} -> format_script_output(latest, state) %{output: []} -> {:reply, {:ok, ""}, state} _ -> {:reply, {:ok, ""}, state} end end def handle_manager_call({:get_scripts, opts}, _from, state) do scripts = filter_scripts(state.scripts, opts) {:reply, {:ok, scripts}, state} end def handle_manager_call({:export_script, script_id, path}, _from, state) do case Map.get(state.scripts, script_id) do nil -> {:reply, {:error, :script_not_found}, state} script -> case export_script_to_file(script, path) do :ok -> {:reply, :ok, state} {:error, reason} -> {:reply, {:error, reason}, state} end end end def handle_manager_call({:import_script, path, opts}, _from, state) do case import_script_from_file(path, opts) do {:ok, script} -> script_id = generate_script_id() new_state = put_in(state.scripts[script_id], script) {:reply, {:ok, script_id}, new_state} {:error, reason} -> {:reply, {:error, reason}, state} end end defp generate_script_id do :crypto.strong_rand_bytes(16) |> Base.encode16() |> binary_part(0, 8) end defp load_script_state(source, type, opts) do %{ id: nil, name: Keyword.get(opts, :name, "Unnamed Script"), type: type, source: source, config: Keyword.get(opts, :config, %{}), status: :idle, error: nil, output: [], metadata: Keyword.get(opts, :metadata, %{}) } end defp validate_script(script) do case validate_script_type(script.type) do :ok -> case validate_script_source(script.source) do :ok -> validate_script_config(script.config) {:error, reason} -> {:error, reason} end {:error, reason} -> {:error, reason} end end defp validate_script_type(type) when type in [:lua, :python, :javascript, :elixir], do: :ok defp validate_script_type(_), do: {:error, :invalid_script_type} defp validate_script_source(source) when is_binary(source) and byte_size(source) > 0, do: :ok defp validate_script_source(_), do: {:error, :invalid_script_source} defp validate_script_config(config) when is_map(config), do: :ok defp validate_script_config(_), do: {:error, :invalid_script_config} defp do_execute_script(script, args, timeout) do # Use Task for safe execution with timeout task = Task.async(fn -> case script.type do :elixir -> execute_elixir_script(script, args, timeout) :lua -> execute_lua_script(script, args, timeout) :python -> execute_python_script(script, args, timeout) :javascript -> execute_javascript_script(script, args, timeout) end end) case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do {:ok, result} -> result nil -> Log.error("Script execution timeout") {:error, :execution_timeout} {:exit, reason} -> Log.error("Script execution failed: #{inspect(reason)}") {:error, :execution_failed} end end defp execute_elixir_script(script, args, _timeout) do module_name = "ScriptModule_#{generate_script_id()}" wrapped_code = """ defmodule #{module_name} do #{script.source} end """ # Use Task for safe code compilation and execution task = Task.async(fn -> [{mod, _bin}] = Code.compile_string(wrapped_code) execute_main_function_if_exported(mod, args) end) case Task.yield(task, 5000) || Task.shutdown(task, :brutal_kill) do {:ok, result} -> result nil -> Log.error("Elixir script compilation/execution timeout") {:error, :execution_failed} {:exit, reason} -> Log.error("Script execution failed: #{inspect(reason)}") {:error, :execution_failed} end end defp execute_lua_script(script, args, timeout) do case execute_external_script("lua", script.source, args, timeout) do {:ok, output} -> {:ok, output} {:error, reason} -> {:error, reason} end end defp execute_python_script(script, args, timeout) do case execute_external_script("python3", script.source, args, timeout) do {:ok, output} -> {:ok, output} {:error, reason} -> {:error, reason} end end defp execute_javascript_script(script, args, timeout) do case execute_external_script("node", script.source, args, timeout) do {:ok, output} -> {:ok, output} {:error, reason} -> {:error, reason} end end defp execute_external_script(interpreter, source, args, timeout) do temp_file = Path.join( System.tmp_dir!(), "raxol_script_#{:crypto.strong_rand_bytes(8) |> Base.encode16()}" ) # Use Task for safe file operations and script execution task = Task.async(fn -> # Write script to temporary file File.write!(temp_file, source) # Execute script with arguments cmd = [temp_file] ++ args case System.cmd(interpreter, cmd, stderr_to_stdout: true) do {output, 0} -> # Clean up _ = File.rm(temp_file) {:ok, String.trim(output)} {error_output, _exit_code} -> # Clean up _ = File.rm(temp_file) {:error, String.trim(error_output)} end end) case Task.yield(task, timeout + 1000) || Task.shutdown(task, :brutal_kill) do {:ok, result} -> result nil -> # Try to clean up temp file _ = Task.start(fn -> File.rm(temp_file) end) {:error, "Script execution timeout"} {:exit, reason} -> # Try to clean up temp file _ = Task.start(fn -> File.rm(temp_file) end) {:error, "Failed to execute script: #{inspect(reason)}"} end end defp filter_scripts(scripts, opts) do scripts |> Enum.filter(fn {_id, script} -> Enum.all?(opts, fn {:type, type} -> script.type == type {:status, status} -> script.status == status _ -> true end) end) |> Map.new() end defp export_script_to_file(script, path) do # Use Task for safe file operations task = Task.async(fn -> # Create directory if it doesn't exist File.mkdir_p!(Path.dirname(path)) # Determine file extension based on script type extension = get_script_extension(script.type) script_path = add_extension_if_needed(path, extension) # Write script source File.write!(script_path, script.source) # Create metadata file metadata_path = script_path <> ".json" metadata = %{ "name" => script.name, "type" => Atom.to_string(script.type), "config" => script.config, "metadata" => script.metadata, "exported_at" => DateTime.utc_now() |> DateTime.to_iso8601() } File.write!(metadata_path, Jason.encode!(metadata, pretty: true)) :ok end) case Task.yield(task, 5000) || Task.shutdown(task, :brutal_kill) do {:ok, :ok} -> :ok nil -> Log.error("Script export timeout") {:error, :export_failed} {:exit, reason} -> Log.error("Script export failed: #{inspect(reason)}") {:error, :export_failed} end end defp get_script_extension(type) do case type do :elixir -> ".exs" :lua -> ".lua" :python -> ".py" :javascript -> ".js" _ -> ".txt" end end defp import_script_from_file(path, opts) do # Use Task for safe file operations task = Task.async(fn -> # Check if path is a directory or file case File.stat(path) do {:ok, %{type: :directory}} -> import_script_from_directory(path, opts) {:ok, %{type: :regular}} -> import_script_from_single_file(path, opts) _ -> {:error, :invalid_path} end end) case Task.yield(task, 5000) || Task.shutdown(task, :brutal_kill) do {:ok, result} -> result nil -> Log.error("Script import timeout") {:error, :import_failed} {:exit, reason} -> Log.error("Script import failed: #{inspect(reason)}") {:error, :import_failed} end end defp import_script_from_directory(path, opts) do case File.ls(path) do {:ok, files} -> case find_script_file(files) do {:ok, script_file} -> load_script_with_metadata(path, script_file, opts) {:error, reason} -> {:error, reason} end {:error, reason} -> {:error, reason} end end defp find_script_file(files) do script_files = Enum.filter(files, &script_file?/1) case script_files do [script_file | _] -> {:ok, script_file} [] -> {:error, :no_script_files} end end defp load_script_with_metadata(path, script_file, opts) do script_path = Path.join(path, script_file) metadata_path = Path.join(path, script_file <> ".json") with {:ok, source} <- File.read(script_path) do metadata = load_script_metadata(metadata_path) type = determine_script_type(metadata, script_file) merged_opts = merge_metadata_with_opts(metadata, opts) {:ok, load_script_state(source, type, merged_opts)} end end defp determine_script_type(metadata, script_file) do type = metadata["type"] || infer_script_type(script_file) String.to_existing_atom(type) end defp merge_metadata_with_opts(metadata, opts) do metadata_opts = [ name: metadata["name"], config: metadata["config"] || %{}, metadata: metadata["metadata"] || %{} ] |> Enum.reject(fn {_key, value} -> is_nil(value) end) Keyword.merge(opts, metadata_opts) end defp import_script_from_single_file(path, opts) do case File.read(path) do {:ok, source} -> # Determine script type from file extension type = infer_script_type(path) {:ok, load_script_state(source, type, opts)} {:error, reason} -> {:error, reason} end end defp script_file?(filename) do String.ends_with?(filename, [".exs", ".lua", ".py", ".js", ".txt"]) end defp load_script_metadata(metadata_path) do case File.read(metadata_path) do {:ok, content} -> case Jason.decode(content) do {:ok, metadata} -> metadata {:error, _} -> %{} end {:error, _} -> %{} end end defp infer_script_type(filename) do case Path.extname(filename) do ".exs" -> :elixir ".lua" -> :lua ".py" -> :python ".js" -> :javascript _ -> :elixir end end defp load_scripts_from_paths(paths) do Enum.each(paths, fn path -> case File.stat(path) do {:ok, %{type: :directory}} -> load_scripts_from_directory(path) {:ok, %{type: :regular}} -> load_script_from_single_file(path) _ -> Log.warning("Invalid script path: #{path}") end end) end defp load_scripts_from_directory(path) do case File.ls(path) do {:ok, entries} -> Enum.each(entries, &process_directory_entry(path, &1)) {:error, reason} -> Log.error("Failed to list directory #{path}: #{inspect(reason)}") end end defp process_directory_entry(base_path, entry) do entry_path = Path.join(base_path, entry) case File.stat(entry_path) do {:ok, %{type: :directory}} -> load_scripts_from_directory(entry_path) {:ok, %{type: :regular}} -> load_script_from_single_file(entry_path) _ -> :ok end end defp load_script_from_single_file(path) do load_script_if_valid_file(script_file?(Path.basename(path)), path) end # Helper functions for if statement elimination defp maybe_load_scripts(false, _paths), do: :ok defp maybe_load_scripts(true, paths), do: load_scripts_from_paths(paths) defp update_script_config_if_valid(config, script, script_id, state) when is_map(config) do script_config = get_script_config(script.config) new_script = %{script | config: Map.merge(script_config, config)} new_state = put_in(state.scripts[script_id], new_script) {:reply, :ok, new_state} end defp update_script_config_if_valid(_config, _script, _script_id, state) do {:reply, {:error, :invalid_script_config}, state} end defp get_script_config(config) when is_map(config), do: config defp get_script_config(_config), do: %{} defp pause_script_if_running(:running, script, script_id, state) do new_script = %{script | status: :paused} new_state = put_in(state.scripts[script_id], new_script) {:reply, :ok, new_state} end defp pause_script_if_running(_status, _script, _script_id, state) do {:reply, {:error, :invalid_script_state}, state} end defp resume_script_if_paused(:paused, script, script_id, state) do new_script = %{script | status: :running} new_state = put_in(state.scripts[script_id], new_script) {:reply, :ok, new_state} end defp resume_script_if_paused(_status, _script, _script_id, state) do {:reply, {:error, :invalid_script_state}, state} end defp stop_script_if_active(status, script, script_id, state) when status in [:running, :paused] do new_script = %{script | status: :idle} new_state = put_in(state.scripts[script_id], new_script) {:reply, :ok, new_state} end defp stop_script_if_active(_status, _script, _script_id, state) do {:reply, {:error, :invalid_script_state}, state} end defp format_script_output(latest, state) when is_binary(latest) do output = handle_single_output(length([latest | []]), latest) {:reply, {:ok, output}, state} end defp format_script_output(latest, state) do {:reply, {:ok, [latest | []]}, state} end defp handle_single_output(1, latest), do: latest defp handle_single_output(_, latest), do: [latest | []] defp execute_main_function_if_exported(mod, args) do case function_exported?(mod, :main, length(args)) do true -> result = apply(mod, :main, args) format_elixir_result(result) false -> {:ok, "Elixir script executed successfully"} end end defp format_elixir_result(result) when is_binary(result), do: {:ok, result} defp format_elixir_result(result), do: {:ok, inspect(result)} defp add_extension_if_needed(path, extension) do case Path.extname(path) do "" -> path <> extension _ -> path end end defp load_script_if_valid_file(false, _path), do: :ok defp load_script_if_valid_file(true, path) do case import_script_from_file(path, []) do {:ok, script} -> script_id = generate_script_id() # Store in process dictionary for now, could be enhanced to use GenServer state _ = Raxol.Core.StateManager.set_state( :scripts, %{{:loaded_script, script_id} => script} ) Log.info("Loaded script: #{script.name} from #{path}") {:error, reason} -> Log.error("Failed to load script from #{path}: #{inspect(reason)}") end end end