Current section
Files
Jump to
Current section
Files
lib/saas_kit.ex
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