defmodule Mix.Tasks.Template.Install do
@moduledoc(
Path.join([__DIR__, "../../../README.md"])
|> File.read!
|> String.replace(~r/\A.*^### Use\s+/ms, "")
|> String.replace(~r/^###.*/ms, ""))
defmodule TargetTemplate do
@moduledoc """
Define naming conventions for template targets
"""
def name_for(project) do
project[:app]
|> to_string()
end
def path_for() do
Path.join(Mix.Utils.mix_home, "templates")
end
def printable_name() do
{ "template", "templates" }
end
def task_name(), do: "template"
end
use Private
use Mix.Task
alias MixTemplates.Cache
@doc nil
@spec run(OptionParser.argv) :: boolean
def run(argv) do
install(argv, [])
end
def install(argv, switches) do
{opts, args} = OptionParser.parse!(argv, strict: switches)
target = TargetTemplate
install_spec =
case parse_args(args, opts) do
{:error, message} -> Mix.raise message <> "\n" <> usage()
install_spec -> install_spec
end
case check_install_spec(install_spec, opts) do
:ok -> :noop
# {:error, message} -> Mix.raise message <> "\n" <> usage()
end
case install_spec do
{:fetcher, _dep_spec} ->
install_from(install_spec, opts, switches)
{:local, src} ->
install_from_local(src)
{:url, src} ->
do_install(target, src, opts)
:project ->
{ :ok, cwd } = File.cwd()
install_from_local(cwd)
end
end
defp install_from({:fetcher, dep_spec}, opts, switches) do
if opts[:sha512] do
Mix.raise "--sha512 is not supported for template.install from git/github/hex\n" <> usage()
end
try do
fetch dep_spec, fn mixfile ->
build(mixfile)
argv = if opts[:force], do: ["--force"], else: []
install(argv, switches)
end
rescue
e in Mix.Error ->
if String.starts_with?(e.message, "No package with name ") do
{ _, _, [hex: name]} = dep_spec
if not String.starts_with?(to_string(name), "gen_template_") do
Mix.shell.info([
"\nI can't find a template called #{name} in hex.\n\nPerhaps you meant ",
:green, "gen_template_#{name}", :reset, "?\n"])
end
end
raise e
end
end
defp install_from_local(src) do
case Cache.install_from_local_tree(src) do
{ :error, reason } ->
Mix.raise(reason)
{ :ok, template_name } ->
Mix.shell.info(["template ",
:green, "#{template_name}",
:reset, " installed successfully"])
:ok
end
end
defp local_dir?(url_or_path) do
File.dir?(url_or_path)
end
defp file_url?(url_or_path) do
URI.parse(url_or_path).scheme in ["http", "https"]
end
@doc """
Checks that the `install_spec` and `opts` are supported
"""
def check_install_spec(_, _) do
:ok
end
@doc """
Returns a list of already installed version of the same archive or escript.
"""
def find_previous_versions(_src, dst) do
if File.exists?(dst), do: [dst], else: []
end
@doc """
For installs involving a `fetch`, this will be executed as the `in_package`.
"""
def build(_) do
IO.puts "Project contains: #{inspect Path.wildcard("*")}"
end
defp usage() do
"\nRun:\n\n mix help template.install\n\nfor more information."
end
defp do_install(name, src, opts) do
src_basename = Path.basename(URI.parse(src).path)
# dst = Path.join(Mix.Local.path_for(name), src_basename)
dst = src_basename
previous_files = find_previous_versions(src, dst)
if opts[:force] || should_install?(name, src, previous_files) do
case Mix.Utils.read_path(src, opts) do
{:ok, _binary} ->
# install(dst, binary, previous_files)
:ok
:badpath ->
Mix.raise """
Expected #{inspect src} to be a URL or a local file path.
Perhaps you meant
“mix template install hex #{src}”
"""
{:local, message} ->
Mix.raise message
{kind, message} when kind in [:remote, :checksum] ->
Mix.raise """
#{message}
Could not fetch #{name} at:
#{src}
Please download the #{name} above manually to your current directory and run:
mix #{name}.install ./#{src_basename}
"""
end
true
else
false
end
end
defp should_install?(name, src, previous_files) do
message = case previous_files do
[] ->
"Are you sure you want to install #{name} #{inspect src}?"
[file] ->
"Found existing #{name}: #{file}.\n" <>
"Are you sure you want to replace it with #{inspect src}?"
files ->
"Found existing #{name}s: #{Enum.map_join(files, ", ", &Path.basename/1)}.\n" <>
"Are you sure you want to replace them with #{inspect src}?"
end
Mix.shell.yes?(message)
end
@doc """
Receives `argv` and `opts` from options parsing and returns an `install_spec`.
"""
def parse_args(argv, opts)
def parse_args([], _opts) do
:project
end
def parse_args([url_or_path], _opts) do
cond do
local_dir?(url_or_path) -> {:local, url_or_path}
file_url?(url_or_path) -> {:url, url_or_path}
true -> {
:error,
"""
Expected a local file path or a file URL. Perhaps you meant:
mix template.install hex #{url_or_path}
"""}
end
end
def parse_args(["github" | rest], opts) do
[repo | rest] = rest
url = "https://github.com/#{repo}.git"
parse_args(["git", url] ++ rest, opts)
end
def parse_args(["git", url], opts) do
parse_args(["git", url, "branch", "master"], opts)
end
def parse_args(["git", url, ref_type, ref], opts) do
case ref_to_config(ref_type, ref) do
{:error, error} ->
{:error, error}
git_config ->
git_opts = git_config ++ [git: url, submodules: opts[:submodules]]
app_name =
if opts[:app] do
opts[:app]
else
"new package"
end
{:fetcher, {String.to_atom(app_name), git_opts}}
end
end
def parse_args(["git" | [_url | rest]], _opts) do
{:error, "received invalid git checkout spec: #{Enum.join(rest, " ")}"}
end
def parse_args(["hex", package_name], opts) do
parse_args(["hex", package_name, ">= 0.0.0"], opts)
end
def parse_args(["hex", package_name, version], opts) do
app_name =
if opts[:app] do
opts[:app]
else
package_name
end
{:fetcher, {String.to_atom(app_name), version, hex: String.to_atom(package_name)}}
end
def parse_args(["hex" | [_package_name | rest]], _opts) do
{:error, "received invalid Hex package spec: #{Enum.join(rest, " ")}"}
end
defp ref_to_config("branch", branch), do: [branch: branch]
defp ref_to_config("tag", tag), do: [tag: tag]
defp ref_to_config("ref", ref), do: [ref: ref]
defp ref_to_config(ref_type, _) do
{:error, "expected one of \"branch\", \"tag\", or \"ref\". Got: \"#{ref_type}\""}
end
@doc """
Prints a list of items in a uniform way. Used for printing the list
of installed archives, escripts, and so on. The first parameter is
the Mix.Local.Target module of the type of items.
"""
@spec print_list(atom, [String.t]) :: :ok
def print_list(target, []) do
{_name, names} = target.printable_name()
Mix.shell.info "No #{names} currently installed."
end
def print_list(target, items) do
{_name, names} = target.printable_name()
Enum.each items, fn item -> Mix.shell.info ["* ", item] end
item_names = String.capitalize(names)
Mix.shell.info "#{item_names} installed at: #{target}"
end
@doc """
A common implementation for uninstalling archives and scripts.
"""
@spec uninstall(atom, OptionParser.argv) :: boolean
def uninstall(target, argv) do
{_, argv, _} = OptionParser.parse(argv)
{ item_name, item_names } = target.printable_name()
root = target
if name = List.first(argv) do
path = Path.join([root, name])
cond do
not File.exists?(path) ->
Mix.shell.error("Could not find a local #{item_name} named #{inspect name}.")
Mix.shell.info("Existing #{item_names} are:")
Mix.Task.run item_name
nil
should_uninstall?(path, item_name) ->
File.rm_rf!(path)
path
true ->
nil
end
else
Mix.raise "No #{item_name} was given to #{item_name}.uninstall"
end
end
defp should_uninstall?(path, item_name) do
Mix.shell.yes?("Are you sure you want to uninstall #{item_name} #{path}?")
end
@doc """
Fetches `dep_spec` with `in_fetcher` and then runs `in_package`.
Generates a new mix project in a temporary directory with the given `dep_spec`
added to a mix.exs. Then, `in_fetcher` is executed in the fetcher project. By
default, this fetches the dependency, but you can provide an `in_fetcher`
during test or for other purposes. After the `in_fetcher` is executed,
`in_package` is executed in the now (presumably) fetched package, with the
package's config overridden with the deps_path and lockfile of the fetcher
package. Also, the Mix env is set to :prod.
"""
@spec fetch(tuple, ((atom) -> any), ((atom) -> any)) :: any
def fetch(dep_spec, in_fetcher \\ &in_fetcher/1, in_package) do
with_tmp_dir fn tmp_path ->
File.mkdir_p!(tmp_path)
File.write! Path.join(tmp_path, "mix.exs"), """
defmodule Mix.Local.Installer.Fetcher.Mixfile do
use Mix.Project
def project do
[app: Mix.Local.Installer.Fetcher,
version: "1.0.0",
deps: [#{inspect dep_spec}]]
end
end
"""
with_mix_env_prod fn ->
Mix.Project.in_project(Mix.Local.Installer.Fetcher, tmp_path, in_fetcher)
package_name = elem(dep_spec, 0)
package_name_string = Atom.to_string(package_name)
package_path = Path.join([tmp_path, "deps", package_name_string])
post_config = [
deps_path: Path.join(tmp_path, "deps"),
lockfile: Path.join(tmp_path, "mix.lock")
]
Mix.Project.in_project(package_name, package_path, post_config, in_package)
end
end
after
:code.purge(Mix.Local.Installer.Fetcher)
:code.delete(Mix.Local.Installer.Fetcher)
end
defp in_fetcher(_mixfile) do
Mix.Task.run("deps.get", [])
end
defp with_tmp_dir(fun) do
unique = :crypto.strong_rand_bytes(4) |> Base.url_encode64(padding: false)
tmp_path = Path.join(System.tmp_dir!(), "mix-local-installer-fetcher-" <> unique)
try do
fun.(tmp_path)
after
File.rm_rf(tmp_path)
end
end
defp with_mix_env_prod(fun) do
previous_env = Mix.env()
try do
Mix.env(:prod)
fun.()
after
Mix.env(previous_env)
end
end
end