defmodule SaasKit do @moduledoc false @allowed_rules ~w(error mix_command generate_file inject_before inject_after append prepend replace wrap_up) def follow_instructions(instructions, feature, opts \\ []) do decisions = Keyword.get(opts, :decisions, %{}) token = Keyword.get(opts, :token, Application.get_env(:saas_kit, :boilerplate_token)) instructions |> Enum.filter(fn %{"rule" => rule} -> rule in @allowed_rules end) |> Enum.reduce_while(:ok, fn instruction, :ok -> instruction = instruction |> Map.put("_saaskit_decisions", decisions) |> Map.put("_saaskit_token", token) case follow_instruction(instruction, feature) do :ok -> {:cont, :ok} :error -> step = Map.get(instruction, "id", "") unless instruction["rule"] == "error" do Mix.shell().info( "#{IO.ANSI.red()}There was an error. Run: mix saaskit.feature.install #{feature} --step #{step}#{IO.ANSI.reset()}" ) end {:halt, {:error, step}} end end) end defp follow_instruction( %{ "rule" => "generate_file", "filename" => filename, "template" => template }, _feature ) do Mix.Generator.create_file(filename, template, force: true) :ok end defp follow_instruction( %{ "rule" => rule, "smart" => true, "filename" => filename, "template" => template } = instruction, feature ) when rule in ~w(inject_before inject_after replace) do with true <- warn_if_file_is_missing(filename), true <- warn_if_file_has_content(filename, template), {:ok, content} <- File.read(filename), {:ok, updated_content} <- get_updated_file(Map.put(instruction, "content", content), feature) do write_file!(filename, updated_content) Mix.shell().info("#{IO.ANSI.green()}* Updated file:#{IO.ANSI.reset()} #{filename}") else false -> :ok _ -> Mix.shell().error( "#{IO.ANSI.red()}* Failed to install file:#{IO.ANSI.reset()} #{filename}" ) :error end end # We will land here when trying to insert a dependency. # We will check if thus is actually about that, otherwise move on. defp follow_instruction( %{ "rule" => rule, "filename" => "mix.exs", "template" => "" <> template } = instruction, feature ) when rule in ~w(inject_before inject_after) and not is_map_key(instruction, "not_insert_deps") do if warn_if_file_is_missing("mix.exs") && warn_if_file_has_content("mix.exs", template) do insert_deps? = Regex.match?(~r/\{:\w+,[^}]*\}/, template) if insert_deps? do new_content = SaasKit.Patcher.inject_dependency(template) write_file!("mix.exs", new_content) Mix.shell().info("#{IO.ANSI.green()}* Updated file:#{IO.ANSI.reset()} mix.exs") else instruction |> Map.put("not_insert_deps", true) |> follow_instruction(feature) end end :ok end defp follow_instruction( %{ "rule" => "inject_before", "last_end" => true, "filename" => filename, "template" => template }, _feature ) do if warn_if_file_is_missing(filename) && warn_if_file_has_content(filename, template) do new_content = File.read!(filename) |> String.trim_trailing() |> String.trim_trailing("end") |> Kernel.<>(template) |> String.trim_trailing() |> Kernel.<>("\nend\n") new_content = String.replace(new_content, ~r/([ \t]*\n){3,}/, "\n\n") write_file!(filename, new_content) Mix.shell().info("#{IO.ANSI.green()}* Updated file:#{IO.ANSI.reset()} #{filename}") end :ok end defp follow_instruction( %{ "rule" => rule, "filename" => filename, "template" => "" <> template, "target" => "" <> target } = instruction, feature ) when rule in ~w(inject_before inject_after) and target != "" do with_smart_fallback(instruction, feature) do if warn_if_file_is_missing(filename) && warn_if_file_has_content(filename, template) do new_content = if rule == "inject_before", do: SaasKit.Patcher.inject_before(instruction), else: SaasKit.Patcher.inject_after(instruction) write_file!(filename, new_content) Mix.shell().info("#{IO.ANSI.green()}* Updated file:#{IO.ANSI.reset()} #{filename}") end :ok end end defp follow_instruction( %{ "rule" => "append", "filename" => filename, "template" => "" <> template }, _feature ) do if warn_if_file_is_missing(filename) && warn_if_file_has_content(filename, template) do existing_content = File.read!(filename) new_content = existing_content |> Kernel.<>("\n") |> Kernel.<>(template) write_file!(filename, new_content) Mix.shell().info("#{IO.ANSI.green()}* Updated file:#{IO.ANSI.reset()} #{filename}") end :ok end defp follow_instruction( %{ "rule" => "prepend", "filename" => filename, "template" => "" <> template }, _feature ) do if warn_if_file_is_missing(filename) && warn_if_file_has_content(filename, template) do existing_content = File.read!(filename) new_content = template |> Kernel.<>("\n") |> Kernel.<>(existing_content) write_file!(filename, new_content) Mix.shell().info("#{IO.ANSI.green()}* Updated file:#{IO.ANSI.reset()} #{filename}") end :ok end defp follow_instruction( %{ "rule" => "replace", "filename" => filename, "template" => "" <> _string_to_insert, "target" => "" <> _string_to_replace } = instruction, _feature ) do if warn_if_file_is_missing(filename) do new_content = SaasKit.Patcher.replace(instruction) write_file!(filename, new_content) Mix.shell().info("#{IO.ANSI.green()}* Updated file:#{IO.ANSI.reset()} #{filename}") end :ok end defp follow_instruction(%{"rule" => "mix_command", "mix_command" => "" <> template}, _) do template = String.replace_prefix(template, "mix ", "") Mix.shell().info("#{IO.ANSI.green()}* Run mix command:#{IO.ANSI.reset()} #{template}") [cmd | args] = String.split(template, " ") System.cmd("mix", ["deps.get"]) Mix.Task.run(cmd, args) :ok end defp follow_instruction(%{"rule" => "wrap_up"} = instruction, feature) do token = Map.get(instruction, "_saaskit_token") base_url = Application.get_env(:saas_kit, :base_url) || "https://livesaaskit.com" url = "#{base_url}/api/boilerplate/installed/#{token}/#{feature}" decisions = Map.get(instruction, "_saaskit_decisions", %{}) Req.post(url, json: %{"decisions" => decisions}) System.cmd("mix", ["deps.get"]) System.cmd("mix", ["format"]) :ok end defp follow_instruction(%{"rule" => "error", "message" => "" <> message}, _) do Mix.shell().error("#{IO.ANSI.red()} #{message} #{IO.ANSI.reset()}") :error end defp follow_instruction(_instruction, _feature) do :ok end defp get_updated_file(instruction, feature) do token = Map.get(instruction, "_saaskit_token") || Application.get_env(:saas_kit, :boilerplate_token) base_url = Application.get_env(:saas_kit, :base_url) || "https://livesaaskit.com" url = "#{base_url}/api/boilerplate/patch_file/#{token}" params = %{ feature: feature, content: Map.get(instruction, "content"), id: Map.get(instruction, "id") } req = Req.post( url, json: params, max_retries: 8, retry_log_level: false, connect_options: [timeout: 60_000] ) case req do {:ok, %{body: %{"template" => "" <> template}}} -> {:ok, template} _ -> {:error, :failed_to_patch} end end defp write_file!(filename, content) do content = String.trim_trailing(content) <> "\n" File.mkdir_p!(Path.dirname(filename)) File.write!(filename, content) end defp warn_if_file_is_missing(filename) do if !File.exists?(filename) do Mix.shell().info( "#{IO.ANSI.yellow()}* file missing, skipping#{IO.ANSI.reset()} #{filename}" ) false else true end end defp warn_if_file_has_content(filename, content) do with true <- File.exists?(filename), existing_content <- File.read!(filename), true <- String.contains?("#{existing_content}", "#{content}") do Mix.shell().info( "#{IO.ANSI.yellow()}* file has content, skipping#{IO.ANSI.reset()} #{filename}" ) false else _ -> true end end defp with_smart_fallback(%{"filename" => filename, "target" => target} = instruction, feature, do: block ) do with true <- File.exists?(filename), existing_content <- File.read!(filename), false <- String.contains?("#{existing_content}", "#{target}") do Map.put(instruction, "smart", true) |> follow_instruction(feature) else _ -> block end end end