lib/cmder.ex

defmodule Cmder do
  @moduledoc """
  Cmder is a runner for generic commands.

  ## Profiles

  You can define multiple cmder profiles.

      config :cmder,
          tailwindcss: [
             cmd: "tailwindcss",
             path: Path.expand("../assets/node_modules/.bin", __DIR__),
             args: ~w(--input=css/app.css --output=../priv/static/assets/app.css --postcss),
             cd: Path.expand("../assets", __DIR__)
             env: %{"NODE_ENV" => "development"}
          ],
          cpx: [
            prevent_zombie: true,
            path: Path.expand("../assets/node_modules/.bin", __DIR__),
            args: ~w(static/**/* ../priv/static),
            cd: Path.expand("../assets", __DIR__)
          ]

  and (for Phoenix) as watcher in your dev.exs:any()

      cmder: {Cmder, :profile, [:tailwindcss, ~w(--watch)]}


  If no `cmd` is given the profile name is being used as command.

  ## Without profiles

  You can invoke cmder also without profiles, the configuration should (for Phoenix) be done
  in the watcher section of your dev.exs:

      cmder: {Cmder, :run, ["tailwindcss", ~w(static/**/* ../priv/static --watch), [
        prevent_zombie: true,
        path: Path.expand("../assets/node_modules/.bin", __DIR__),
        cd: Path.expand("../assets", __DIR__)
      ]]}


  Zombie processes can be prevented by adding 'prevent_zombie' key with a truthy value. On systems
  with bash available the script if [Ports|https://hexdocs.pm/elixir/Port.html#module-zombie-operating-system-processes]
  will be used as wrapper.
  """

  use Application
  require Logger

  @doc false
  def start(_, _) do
    Supervisor.start_link([], strategy: :one_for_one)
  end

  @doc """
  Runs the given profile with `args`.

  The given args will be appended to the configured args.
  The task output will be streamed directly to stdio. It
  returns the status of the underlying call.
  """
  def profile(profile, extra_args) when is_atom(profile) and is_list(extra_args) do
    profile
    |> config_for!()
    |> execute(extra_args)
  end

  def run(cmd, extra_args, opts) when is_binary(cmd) and is_list(extra_args) do
    opts
    |> Keyword.put(:cmd, cmd)
    |> Keyword.put_new(:args, [])
    |> execute(extra_args)
  end

  defp execute(opts, extra_args) do
    opts =
      opts
      |> maybe_wrap_in_zombie()
      |> Keyword.put_new(:cd, File.cwd!())
      |> Keyword.put(:into, IO.stream(:stdio, :line))
      |> Keyword.put(:stderr_to_stdout, true)
      |> Keyword.delete(:prevent_zombie)

    {cmd, opts} = Keyword.pop!(opts, :cmd)
    {path, opts} = Keyword.pop(opts, :path, "")
    {args, opts} = Keyword.pop(opts, :args, "")

    cmd = Path.join(path, cmd)

    cmd
    |> System.cmd(args ++ extra_args, opts)
    |> elem(1)
  end

  defp config_for!(profile) when is_atom(profile) do
    config =
      Application.get_env(:cmder, profile) ||
        raise ArgumentError, """
        unknown cmder profile. Make sure the profile is defined in your config/config.exs file, such as:

            config :cmder,
              #{profile}: [
                cmd: "#{profile}"
                path: "/usr/bin",
                args: ~w(--input=css/app.css --output=../priv/static/assets/app.css --postcss),
                cd: Path.expand("../assets", __DIR__),
                env: %{"NODE_ENV" => "development"}
              ]
        """

    config
    |> Keyword.put_new(:cmd, Atom.to_string(profile))
    |> Keyword.put_new(:args, [])
  end

  defp maybe_wrap_in_zombie(config) do
    if System.find_executable("bash") && config[:prevent_zombie] do
      config
      |> Keyword.put(:args, [Path.join(config[:path] || "", config[:cmd]) | config[:args]])
      |> Keyword.put(:cmd, "zombie.sh")
      |> Keyword.put(:path, :code.priv_dir(:cmder))
    else
      config
    end
  end
end