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