Current section

Files

Jump to
igniter_new lib mix tasks igniter.new.ex
Raw

lib/mix/tasks/igniter.new.ex

defmodule Mix.Tasks.Igniter.New do
@moduledoc """
Creates a new project using `mix new`, and adds `igniter` to the project.
## Options
All options are passed through to underlying installers, except for the following:
* `--install` - A comma-separated list of dependencies to install using
`mix igniter.install` after creating the project.
* `--example` - Request example code to be added to the project when installing packages.
* `--with` - The command to use instead of `new`, i.e `phx.new`
* `--with-args` - Additional arguments to pass to the installer provided in `--with`
* `--yes` or `-y` - Skips confirmations during installers. The `-y` option cannot be applied
to the `--with` command, as it may or may not support it. Use `--with-args`
to provide arguments to that command.
* `--no-installer-version-check` - skip the version check for the latest igniter_new version
* `--no-git` - Skip initializing a git repository in the project directory and commit the initial state.
## Options for `mix.new`
* `--module` - The base module name to use for the project.
* `--sup` - Generates an OTP application skeleton including a supervision tree.
* `--umbrella` - Generates an umbrella project.
Example
mix igniter.new my_project --install foo,bar,baz --with=phx.new --with-args="--no-ecto"
"""
@shortdoc "Creates a new Igniter application"
use Mix.Task
@igniter_version "~> 0.6"
@installer_version Igniter.New.MixProject.project()[:version]
@impl Mix.Task
def run(argv) do
{argv, positional} = extract_positional_args(argv)
name =
case positional do
[name | _] ->
name
_ ->
Mix.shell().error("""
Required positional argument missing: project_name.
Usage:
mix igniter.new project_name [options]
""")
exit({:shutdown, 1})
end
if String.starts_with?(name, "-") do
Mix.shell().error("""
The first positional argument must be a project name that starts with a dash, got: #{name}
""")
exit({:shutdown, 1})
end
{options, _, _} =
OptionParser.parse(argv,
strict: [
install: :keep,
local: :string,
example: :boolean,
with: :string,
module: :string,
sup: :boolean,
umbrella: :boolean,
installer_version_check: :boolean,
git: :boolean
],
aliases: [i: :install, l: :local, e: :example, w: :with]
)
install_with = options[:with] || "new"
if String.match?(install_with, ~r/\s/) do
Mix.shell().error("The --with option must not contain any spaces, got: #{install_with}")
exit({:shutdown, 1})
end
install =
options
|> Keyword.get_values(:install)
|> List.wrap()
|> Enum.join(",")
|> String.split(",", trim: true)
if File.exists?(name) do
Mix.shell().error("""
The directory #{name} already exists. You must either:
1. remove or move it
2. If you are trying to modify an existing project add `{:igniter` to the project, if it is not
already added, and then run `mix igniter.install #{Enum.join(install, ",")}` inside the project
""")
exit({:shutdown, 1})
end
version_task =
if Keyword.get(options, :installer_version_check, true) do
get_latest_version("igniter_new")
end
with_args =
[name | with_args(argv)]
with_args =
if install_with == "phx.new" do
with_args ++ ["--install"]
else
with_args
end
with_args =
if options[:module] do
with_args ++ ["--module", options[:module]]
else
with_args
end
with_args =
if options[:sup] do
with_args ++ ["--sup"]
else
with_args
end
with_args =
if options[:umbrella] do
with_args ++ ["--umbrella"]
else
with_args
end
yes =
if "--yes" in argv or "-y" in argv do
"--yes"
end
do_warn_outdated(version_task, yes: yes)
Mix.Task.run(install_with, with_args)
version_requirement =
if options[:local] do
local = Path.join(["..", options[:local]])
"path: #{inspect(local)}, override: true"
else
inspect(@igniter_version)
end
if "--umbrella" in with_args or "--umbrella" in argv do
Mix.shell().exit("""
igniter.new is not currently compatible with umbrella applications
Additionally, many package installers do not support umbrella applications.
If you are sure that you want to use umbrella applications (there are plenty
of good reasons), please generate the application using `mix #{install_with}`,
and then run installers from individual applications.
""")
exit({:shutdown, 1})
end
File.cd!(name)
contents =
"mix.exs"
|> File.read!()
if String.contains?(contents, "{:igniter") do
Mix.shell().info(
"It looks like the project already exists and igniter is already installed, not adding it to deps."
)
else
# the spaces are required here to avoid the need for a format
new_contents =
contents
|> add_igniter_dep(version_requirement)
|> Code.format_string!()
File.write!("mix.exs", new_contents)
end
Igniter.Installer.Loading.with_spinner(
"Fetching and compiling dependencies",
fn ->
System.cmd("mix", ["deps.get"])
System.cmd("mix", ["deps.compile", "--long-compilation-threshold", "0"])
end
)
if !Enum.empty?(install) do
example =
if options[:example] do
"--example"
end
install_args =
Enum.filter([Enum.join(install, ","), example, yes], & &1)
old_undefined = Code.get_compiler_option(:no_warn_undefined)
old_relative_paths = Code.get_compiler_option(:relative_paths)
old_ignore_module_conflict = Code.get_compiler_option(:ignore_module_conflict)
try do
Code.compiler_options(
relative_paths: false,
no_warn_undefined: :all,
ignore_module_conflict: true
)
_ = Code.compile_file("mix.exs")
after
Code.compiler_options(
relative_paths: old_relative_paths,
no_warn_undefined: old_undefined,
ignore_module_conflict: old_ignore_module_conflict
)
end
rest_args =
try do
rest_args(argv)
rescue
_ ->
[]
end
Mix.Task.run(
"igniter.install",
install_args ++
rest_args ++ ["--yes-to-deps", "--from-igniter-new", "--new-with", "phx-new"]
)
end
if Keyword.get(options, :git, true) do
initialize_git_repo()
end
:ok
end
defp do_warn_outdated(version_task, opts) do
if version_task do
try do
# if we get anything else than a `Version`, we'll get a MatchError
# and fail silently
%Version{} = latest_version = Task.await(version_task, 3_000)
maybe_warn_outdated(latest_version, opts)
rescue
_e ->
:ok
catch
:abort ->
exit({:shutdown, 1})
:exit, _e ->
:ok
end
end
end
defp with_args(argv, acc \\ [])
defp with_args([], acc) do
acc
end
defp with_args(["--with-args=" <> args | rest], acc) do
with_args(rest, acc ++ OptionParser.split(args))
end
defp with_args(["--with-args", next | rest], acc) do
with_args(rest, acc ++ OptionParser.split(next))
end
defp with_args([_next | rest], acc) do
with_args(rest, acc)
end
defp extract_positional_args(argv) do
do_extract_positional_args(argv, [], [])
end
defp do_extract_positional_args([], argv, positional), do: {argv, positional}
defp do_extract_positional_args(argv, got_argv, positional) do
case OptionParser.next(argv, switches: []) do
{_, _key, true, rest} ->
do_extract_positional_args(
rest,
got_argv ++ [Enum.at(argv, 0)],
positional
)
{_, _key, _value, rest} ->
count_consumed = Enum.count(argv) - Enum.count(rest)
do_extract_positional_args(
rest,
got_argv ++ Enum.take(argv, count_consumed),
positional
)
{:error, rest} ->
[first | rest] = rest
do_extract_positional_args(rest, got_argv, positional ++ [first])
end
end
@flags ~w(example sup umbrella installer-version-check no-installer-version-check git no-git)
@flags_with_values ~w(install local with with-args module)
@switches ~w(e)
@switches_with_values ~w(i l)
# I don't feel like I should have to do this
# seems like something missing in OptionParser
defp rest_args(args) do
args
|> Enum.flat_map(&String.split(&1, ~r/(?<!\\)=/, parts: 2, trim: true))
|> do_rest_args()
end
defp do_rest_args([]), do: []
defp do_rest_args(["--" <> flag | rest])
when flag in @flags do
do_rest_args(rest)
end
defp do_rest_args(["--" <> flag, _value | rest]) when flag in @flags_with_values do
do_rest_args(rest)
end
defp do_rest_args(["-" <> flag, "-" <> next | rest])
when flag in @switches or flag in @switches_with_values do
do_rest_args(["-" <> next | rest])
end
defp do_rest_args(["-" <> flag, _value | rest]) when flag in @switches_with_values do
do_rest_args(rest)
end
defp do_rest_args(["-" <> flag, "-" <> next | rest]) do
["-#{flag}" | do_rest_args(["-" <> next | rest])]
end
defp do_rest_args(["-" <> flag, next | rest]) do
["-#{flag}", next | do_rest_args(rest)]
end
defp do_rest_args(["--" <> flag]) when flag in @flags or flag in @flags_with_values do
[]
end
defp do_rest_args(["-" <> flag]) when flag in @switches or flag in @switches_with_values do
[]
end
defp do_rest_args([other]), do: [other]
@doc false
def add_igniter_dep(contents, version_requirement) do
if String.contains?(contents, "defp deps do\n []") do
String.replace(
contents,
"defp deps do\n []",
"defp deps do\n [{:igniter, #{version_requirement}, only: [:dev, :test]}]"
)
else
String.replace(
contents,
"defp deps do\n [\n",
"defp deps do\n [\n {:igniter, #{version_requirement}, only: [:dev, :test]},\n"
)
end
end
@doc false
def igniter_version, do: @igniter_version
defp initialize_git_repo do
Igniter.Installer.Loading.with_spinner(
"Initializing local git repository, staging all files, and committing",
fn ->
case maybe_init_git() do
{_, 0} ->
case System.cmd("git", ["add", "."]) do
{_, 0} ->
case System.cmd("git", ["commit", "-m", "🔥 initial commit 🔥"]) do
{_, 0} ->
Mix.shell().info("Git repository initialized and initial commit created.")
{output, _} ->
Mix.shell().error("Failed to create initial commit: #{output}")
end
{output, _} ->
Mix.shell().error("Failed to add files to git: #{output}")
end
{output, _} ->
Mix.shell().error("Failed to initialize git repository: #{output}")
end
end
)
end
defp maybe_init_git() do
skip_git_check? = System.get_env("IGNITER_SKIP_GIT_CHECK") == "true"
if skip_git_check? do
System.cmd("git", ["init"])
else
case System.cmd("git", ["rev-parse", "--is-inside-work-tree"]) do
{_, 0} -> {true, 0}
{_, _} -> System.cmd("git", ["init"])
end
end
end
defp maybe_warn_outdated(latest_version, opts) do
if Version.compare(@installer_version, latest_version) == :lt do
if opts[:yes] do
Mix.shell().info([
:yellow,
"A new version of igniter.new is available:",
:green,
" v#{latest_version}",
:reset,
".",
"\n",
"You are currently running ",
:red,
"v#{@installer_version}",
:reset,
".\n",
"To update, run:\n\n",
" $ mix local.igniter\n"
])
else
continue? =
Mix.shell().yes?(
IO.iodata_to_binary(
IO.ANSI.format([
:yellow,
"A new version of igniter.new is available:",
:green,
" v#{latest_version}",
:reset,
".",
"\n",
"You are currently running ",
:red,
"v#{@installer_version}",
:reset,
".\n",
"To update, run:\n\n",
" $ mix local.igniter\n\n",
"Would you like to continue with ",
:red,
"v#{@installer_version} ",
:reset,
"anyway?"
])
)
)
if !continue? do
throw(:abort)
end
end
end
end
# we need to parse JSON, so we only check for new versions on Elixir 1.18+
if Version.match?(System.version(), "~> 1.18") do
defp get_latest_version(package) do
Task.async(fn ->
# ignore any errors to not prevent the generators from running
# due to any issues while checking the version
try do
with {:ok, package} <- get_package(package) do
versions =
for release <- package["releases"],
version = Version.parse!(release["version"]),
# ignore pre-releases like release candidates, etc.
version.pre == [] do
version
end
Enum.max(versions, Version)
end
rescue
e -> {:error, e}
catch
:exit, e -> {:error, :exit, e}
end
end)
end
defp get_package(name) do
http_options =
[
ssl: [
verify: :verify_peer,
cacerts: :public_key.cacerts_get(),
depth: 2,
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
],
versions: [:"tlsv1.2", :"tlsv1.3"]
]
]
options = [body_format: :binary]
Application.ensure_all_started(:ssl)
:inets.start()
case :httpc.request(
:get,
{~c"https://hex.pm/api/packages/#{name}",
[{~c"user-agent", ~c"Mix.Tasks.Igniter.New/#{@installer_version}"}]},
http_options,
options
) do
{:ok, {{_, 200, _}, _headers, body}} ->
{:ok, JSON.decode!(body)}
{:ok, {{_, status, _}, _, _}} ->
{:error, status}
{:error, reason} ->
{:error, reason}
end
end
else
defp get_latest_version(_), do: nil
end
end