From 7f33c6f0c8d856ff116dcbd8b21a95209623430b Mon Sep 17 00:00:00 2001 From: May Matyi Date: Wed, 6 Dec 2023 10:05:57 -0800 Subject: [PATCH 1/2] Improved lvn.install task --- lib/mix/tasks/install.ex | 267 +++++++++++++++++++++++---------------- mix.exs | 4 +- mix.lock | 1 + 3 files changed, 159 insertions(+), 113 deletions(-) diff --git a/lib/mix/tasks/install.ex b/lib/mix/tasks/install.ex index b618fd3e..9a931fd3 100644 --- a/lib/mix/tasks/install.ex +++ b/lib/mix/tasks/install.ex @@ -4,82 +4,20 @@ defmodule Mix.Tasks.Lvn.Install do @requirements ["app.config"] - @template_projects_repo "https://github.com/liveview-native/liveview-native-template-projects" - @template_projects_version "0.0.1" - @shortdoc "Installs LiveView Native." def run(args) do {parsed_args, _, _} = OptionParser.parse(args, strict: [namespace: :string]) - # Define some paths for the host project - current_path = File.cwd!() - mix_config_path = Path.join(current_path, "mix.exs") - app_config_path = Path.join(current_path, "/config/config.exs") - app_namespace = parsed_args[:namespace] || infer_app_namespace(mix_config_path) - build_path = Path.join(current_path, "_build") - libs_path = Path.join(build_path, "dev/lib") - - # Ask the user some questions about their app - preferred_route_input = - IO.gets( - "What path should native clients connect to by default? Leave blank for default: \"/\")\n" - ) - - preferred_prod_url_input = - IO.gets("What URL will you use in production? Leave blank for default: \"example.com\")\n") - - preferred_route = String.trim(preferred_route_input) - _preferred_route = if preferred_route == "", do: "/", else: preferred_route - preferred_prod_url = String.trim(preferred_prod_url_input) - _preferred_prod_url = if preferred_prod_url == "", do: "example.com", else: preferred_prod_url - - # Get a list of compiled libraries - libs = File.ls!(libs_path) - - # Clone the liveview-native-template-projects repo. This repo contains - # templates for various native platforms in their respective tools - # (Xcode, Android Studio, etc.) - clone_template_projects() - template_projects_path = Path.join(build_path, "lvn_tmp/liveview-native-template-projects") - template_libs = File.ls!(template_projects_path) - - # Find libraries compiled for the host project that have available - # template projects - supported_libs = Enum.filter(libs, &(&1 in template_libs)) - - # Run the install script for each template project. Install scripts are - # responsible for generating platform-specific template projects and return - # information about that platform to be applied to the host project's Mix - # configuration. - platform_names = - Enum.map(supported_libs, fn lib -> - status_message("configuring", "#{lib}") - - # Run the project-specific install script, passing info about the host - # Phoenix project. - lib_path = Path.join(template_projects_path, "/#{lib}") - script_path = Path.join(lib_path, "/install.exs") - - cmd_opts = [ - script_path, - "--app-name", - app_namespace, - "--app-path", - current_path, - "--platform-lib-path", - lib_path - ] - - with {platform_name, 0} <- System.cmd("elixir", cmd_opts) do - String.trim(platform_name) - end - end) - - generate_native_exs_if_needed(current_path, platform_names) - update_config_exs_if_needed(app_config_path) + # 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) - # Clear _build path to ensure it's rebuilt with new Config - File.rm_rf(build_path) + 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") @@ -89,23 +27,110 @@ defmodule Mix.Tasks.Lvn.Install do ### - defp clone_template_projects do - with {:ok, current_path} <- File.cwd(), - tmp_path <- Path.join(current_path, "_build/lvn_tmp"), - _ <- File.rm_rf(tmp_path), - :ok <- File.mkdir(tmp_path) do - status_message("downloading", "template project files") - - System.cmd("git", [ - "clone", - "-b", - @template_projects_version, - @template_projects_repo, - tmp_path <> "/liveview-native-template-projects" - ]) + 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") + + %{ + 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), @@ -139,56 +164,74 @@ defmodule Mix.Tasks.Lvn.Install do end end - defp generate_native_exs_if_needed(current_path, platform_names) do - platform_names_string = Enum.join(platform_names, ",") - native_config_path = Path.join(current_path, "/config/native.exs") + 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 - if File.exists?(native_config_path) do - IO.puts("native.exs already exists, skipping...") - else - status_message("creating", "config/native.exs") + 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 - # Generate native.exs and write it to config path - lvn_configuration = native_exs_body(platform_names_string) - {:ok, native_config} = File.open(native_config_path, [:write]) - IO.binwrite(native_config, lvn_configuration) - File.close(native_config) + 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 update_config_exs_if_needed(app_config_path) do + 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 - status_message("updating", "config/config.exs") + 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" <> import_string + 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 native_exs_body(platform_names_string) do - """ - # This file is responsible for configuring LiveView Native. - # It is auto-generated when running `mix lvn.install`. - import Config - - config :live_view_native, plugins: [#{platform_names_string}] - """ + defp format_config_files do + System.cmd("mix", ["format", "*.exs"], cd: "config") end - defp status_message(label, message) do - formatted_message = IO.ANSI.green() <> "* #{label} " <> IO.ANSI.reset() <> message - - IO.puts(formatted_message) + 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 diff --git a/mix.exs b/mix.exs index 9751327e..ed2adb38 100644 --- a/mix.exs +++ b/mix.exs @@ -50,7 +50,9 @@ defmodule LiveViewNative.MixProject do {:makeup_eex, ">= 0.1.1", only: :dev, runtime: false}, {:dialyxir, "~> 1.0", only: :dev, runtime: false}, {:meeseeks, "~> 0.17.0"}, - {:live_view_native_platform, "0.2.0-beta.2"} + {:owl, "~> 0.8", runtime: false}, + {:deep_merge, "~> 1.0"}, + {:live_view_native_platform, "0.2.0-beta.2"}, ] end diff --git a/mix.lock b/mix.lock index daf718dd..a6107d0a 100644 --- a/mix.lock +++ b/mix.lock @@ -22,6 +22,7 @@ "meeseeks_html5ever": {:hex, :meeseeks_html5ever, "0.14.3", "7827c6ce393d9f99dd0220c356fd66ee7101718037ec6f7f18d4bcba84ef1798", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6.1", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "6b69573b97120fcc6e97045178ad085fd3ee10a5b49c1e9ebb8a28bd4a9c538b"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, + "owl": {:hex, :owl, "0.8.0", "0ef925cb784311093d4e3734822960cbdbdb13b095d748bb5bc82abcd5b56732", [:mix], [], "hexpm", "0a5586ceb1a12f4bbda90e330c20e6ea034552335d09466c10e4218c98977529"}, "phoenix": {:hex, :phoenix, "1.7.7", "4cc501d4d823015007ba3cdd9c41ecaaf2ffb619d6fb283199fa8ddba89191e0", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "8966e15c395e5e37591b6ed0bd2ae7f48e961f0f60ac4c733f9566b519453085"}, "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.0", "3f3531c835e46a3b45b4c3ca4a09cef7ba1d0f0d0035eef751c7084b8adb1299", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "29875f8a58fb031f2dc8f3be025c92ed78d342b46f9bbf6dfe579549d7c81050"}, From 2c9937efc346b2a66b4f58ffe15cb3be78196b2f Mon Sep 17 00:00:00 2001 From: May Matyi Date: Thu, 7 Dec 2023 15:46:04 -0800 Subject: [PATCH 2/2] Ensure preferred_route begins with forward slash --- lib/mix/tasks/install.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/mix/tasks/install.ex b/lib/mix/tasks/install.ex index 9a931fd3..ba9446ed 100644 --- a/lib/mix/tasks/install.ex +++ b/lib/mix/tasks/install.ex @@ -106,6 +106,7 @@ defmodule Mix.Tasks.Lvn.Install do # 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"),