defmodule Mix.Tasks.Crosswake.Install do
use Mix.Task
alias Crosswake.Install.Manifest
alias Crosswake.Install.Patcher
@shortdoc "Additively installs Crosswake into a Phoenix host"
@moduledoc """
Bootstraps a Phoenix host for Crosswake with explicit markers, a host-owned policy
module, and a machine-readable install manifest.
"""
@switches [
target: :string,
router: :string,
web_module: :string,
policy_module: :string,
manifest_path: :string
]
@impl Mix.Task
def run(args) do
Mix.shell().info("Crosswake installer: additive, idempotent, and marker-driven.")
{opts, _argv, invalid} = OptionParser.parse(args, strict: @switches)
if invalid != [] do
Mix.raise("invalid options: #{inspect(invalid)}")
end
target = Path.expand(opts[:target] || File.cwd!())
router_path = Path.expand(opts[:router] || infer_router_path!(target), target)
web_module = opts[:web_module] || infer_web_module!(router_path)
policy_module = opts[:policy_module] || "#{web_module}.Crosswake.Policy"
policy_path = policy_path(target, router_path)
manifest_path =
Path.expand(opts[:manifest_path] || "priv/crosswake/install_manifest.json", target)
{:ok, router_result} = Patcher.patch_router(router_path, policy_module)
{policy_action, _policy_contents} =
ensure_policy_module(policy_path, policy_module, router_path)
{:ok, manifest_action} =
Manifest.write(manifest_path, %{
crosswake_version: Mix.Project.config()[:version] || "dev",
router_path: Path.relative_to(router_path, target),
web_module: web_module,
policy_module: policy_module,
files: %{
created_or_reused: [
Path.relative_to(router_path, target),
Path.relative_to(policy_path, target),
Path.relative_to(manifest_path, target)
]
},
markers: Patcher.marker_lines()
})
Mix.shell().info("""
Crosswake install complete for #{Path.basename(target)}
router: #{Path.relative_to(router_path, target)} (#{format_router_actions(router_result.actions)})
policy module: #{Path.relative_to(policy_path, target)} (#{policy_action})
install manifest: #{Path.relative_to(manifest_path, target)} (#{manifest_action})
""")
end
defp infer_router_path!(target) do
case Path.wildcard(Path.join([target, "lib", "*_web", "router.ex"])) do
[router_path] ->
router_path
[] ->
Mix.raise("could not find lib/*_web/router.ex under #{target}")
paths ->
Mix.raise(
"found multiple router files, pass --router explicitly: #{Enum.join(paths, ", ")}"
)
end
end
defp infer_web_module!(router_path) do
router_path
|> Path.dirname()
|> Path.basename()
|> Macro.camelize()
end
defp policy_path(target, router_path) do
router_dir = Path.dirname(router_path)
Path.join([target, Path.relative_to(router_dir, target), "crosswake", "policy.ex"])
end
defp ensure_policy_module(path, policy_module, router_path) do
File.mkdir_p!(Path.dirname(path))
contents = policy_module_template(policy_module, router_path)
case File.read(path) do
{:ok, existing} ->
{:reused, existing}
{:error, :enoent} ->
File.write!(path, contents)
{:created, contents}
{:error, reason} ->
Mix.raise("could not write policy module #{path}: #{:file.format_error(reason)}")
end
end
defp policy_module_template(policy_module, router_path) do
module_template_path =
Application.app_dir(:crosswake, "priv/templates/crosswake/policy_module.ex")
router_module = router_module_from_path(router_path)
EEx.eval_file(module_template_path,
assigns: [policy_module: policy_module, router_module: router_module]
)
end
defp router_module_from_path(router_path) do
router_path
|> Path.dirname()
|> Path.basename()
|> Kernel.<>(".Router")
|> then(&Macro.camelize(&1))
end
defp format_router_actions(actions) do
actions
|> Enum.map(&Atom.to_string/1)
|> Enum.join(", ")
end
end