defmodule Mix.Tasks.Lvn.Install do @moduledoc "Installer Mix task for LiveView Native: `mix lvn.install`" use Mix.Task @requirements ["app.config"] @shortdoc "Installs LiveView Native." def run(args) do {parsed_args, _, _} = OptionParser.parse(args, strict: [namespace: :string]) # Get all Mix tasks for LiveView Native client libraries valid_mix_tasks = get_installer_mix_tasks() host_project_config = get_host_project_config(parsed_args) run_all_install_tasks(valid_mix_tasks, host_project_config) native_config = merge_native_config(valid_mix_tasks) generate_native_exs_if_needed(host_project_config, native_config) update_config_exs_if_needed(host_project_config) clean_build_path(host_project_config) format_config_files() IO.puts("\nYour Phoenix app is ready to use LiveView Native!\n") IO.puts("Platform-specific project files have been placed in the \"native\" directory\n") :ok end ### defp run_all_install_tasks(mix_tasks, host_project_config) do mix_tasks |> Enum.map(&prompt_task_settings/1) |> Enum.map(&run_install_task(&1, host_project_config)) end defp prompt_task_settings(%{client_name: client_name, prompts: [_ | _] = prompts} = task) do prompts |> Enum.reduce_while({:ok, task}, fn {prompt_key, prompt_settings}, {:ok, acc} -> case prompt_task_setting(prompt_settings, client_name) do {:error, message} -> Owl.IO.puts([Owl.Data.tag("#{client_name}: #{message}", :yellow)]) {:halt, {:error, acc}} result -> settings = Map.get(acc, :settings, %{}) updated_settings = Map.put(settings, prompt_key, result) {:cont, {:ok, Map.put(acc, :settings, updated_settings)}} end end) end defp prompt_task_setting(%{ignore: true}, _client_name), do: true defp prompt_task_setting(%{type: :confirm, label: label} = task, client_name) do if Owl.IO.confirm(message: "#{client_name}: #{label}", default: true) do if is_function(task[:on_yes]), do: apply(task[:on_yes], []) else if is_function(task[:on_no]), do: apply(task[:on_no], []) end end defp prompt_task_setting(%{type: :multiselect, label: label, options: options, default: default} = task, client_name) do default_label = Map.get(task, :default_label, inspect(default)) case Owl.IO.multiselect(options, label: "#{client_name}: #{label} (Space-delimited, leave blank for default: #{default_label})") do [] -> default || [] result -> result end end defp prompt_task_setting(_task, _client_name), do: nil defp run_install_task(result, host_project_config) do case result do {:ok, %{client_name: client_name, mix_task: mix_task, settings: settings}} -> Owl.IO.puts([Owl.Data.tag("* generating ", :green), "#{client_name} project files"]) mix_task.run(["--host-project-config", host_project_config, "--task-settings", settings]) _ -> :skipped end end defp get_installer_mix_tasks do Mix.Task.load_all() |> Enum.filter(&function_exported?(&1, :lvn_install_config, 0)) |> Enum.map(fn module -> module |> apply(:lvn_install_config, []) |> Map.put(:mix_task, module) end) end defp get_host_project_config(parsed_args) do # Define some paths for the host project current_path = File.cwd!() mix_config_path = Path.join(current_path, "mix.exs") build_path = Path.join(current_path, "_build") # Ask the user some questiosn about the native project configuration preferred_route = prompt_config_option("What path should native clients connect to by default?", "/") preferred_prod_url = prompt_config_option("What URL will you use in production?", "example.com") preferred_route = if String.starts_with?(preferred_route, "/"), do: preferred_route, else: "/#{preferred_route}" %{ app_config_path: Path.join(current_path, "/config/config.exs"), app_namespace: parsed_args[:namespace] || infer_app_namespace(mix_config_path), build_path: build_path, current_path: current_path, libs_path: Path.join(build_path, "dev/lib"), mix_config_path: mix_config_path, native_path: Path.join(current_path, "native"), preferred_prod_url: preferred_prod_url, preferred_route: preferred_route } end defp prompt_config_option(prompt_message, default_value) do "#{prompt_message} (Leave blank for default: \"#{default_value}\")\n" |> IO.gets() |> String.trim() |> default_if_blank(default_value) end defp default_if_blank(value, default_value) do if value == "", do: default_value, else: value end def infer_app_namespace(config_path) do with {:ok, config} <- File.read(config_path), {:ok, mix_project_ast} <- Code.string_to_quoted(config), {:ok, namespace} <- find_mix_project_namespace(mix_project_ast) do "#{namespace}" else _ -> raise "Could not infer Mix project namespace from mix.exs. Please provide it manually using the --namespace argument." end end defp find_mix_project_namespace(ast) do case ast do ast when is_list(ast) -> ast |> Enum.reduce_while({:error, :cannot_infer_app_name}, fn node, _acc -> {status, result} = find_mix_project_namespace(node) acc_op = if status == :ok, do: :halt, else: :cont {acc_op, {status, result}} end) {:defmodule, _, [{:__aliases__, _, [namespace, :MixProject]} | _rest]} -> {:ok, namespace} {:__block__, _, contents} -> find_mix_project_namespace(contents) _ -> {:error, :cannot_infer_app_name} end end defp merge_native_config(mix_tasks) do mix_tasks |> Enum.reduce(%{}, fn %{mix_config: mix_config}, acc -> DeepMerge.deep_merge(acc, mix_config) end) end defp generate_native_exs_if_needed(%{current_path: current_path}, %{} = native_config) do native_config_path = Path.join(current_path, "/config/native.exs") native_config_already_exists? = File.exists?(native_config_path) generate_native_config? = if native_config_already_exists?, do: Owl.IO.confirm(message: "native.exs already exists, regenerate it?", default: false), else: true if generate_native_config? do Owl.IO.puts([Owl.Data.tag("* creating ", :green), "config/native.exs"]) lvn_configuration = native_exs_body(native_config) File.write(native_config_path, lvn_configuration) :ok else IO.puts("native.exs already exists, skipping...") end end defp native_exs_body(%{} = native_config) do config_body = native_config |> Enum.map(fn {key, config} -> config_value = inspect(config) config_value_formatted = String.slice(config_value, 1, String.length(config_value) - 2) "config :#{key}, #{config_value_formatted}" end) |> Enum.join("\n\n") """ # This file is responsible for configuring LiveView Native. # It is auto-generated when running `mix lvn.install`. import Config #{config_body} """ end defp update_config_exs_if_needed(%{app_config_path: app_config_path}) do # Update project's config.exs to import native.exs if needed. import_string = "import_config \"native.exs\"" full_import_string = Enum.join(["\n", "# Import LiveView Native configuration", import_string], "\n") {:ok, app_config_body} = File.read(app_config_path) if String.contains?(app_config_body, import_string) do IO.puts("config.exs already imports native.exs, skipping...") else Owl.IO.puts([Owl.Data.tag("* updating ", :yellow), "config/config.exs"]) {:ok, app_config} = File.open(app_config_path, [:write]) updated_app_config_body = app_config_body <> "\n" <> full_import_string IO.binwrite(app_config, updated_app_config_body) File.close(app_config) end end defp format_config_files do System.cmd("mix", ["format", "*.exs"], cd: "config") end defp clean_build_path(%{build_path: build_path}) do # Clear _build path to ensure it's rebuilt with new Config File.rm_rf(build_path) end end