lib/nodelix.ex

defmodule Nodelix do
  use Application
  require Logger

  alias Nodelix.VersionManager

  @moduledoc """
  Nodelix is an installer and runner for [Node.js](https://nodejs.org/).

  > ### Warning {: .warning}
  >
  > This is a pre-release version. As such, anything _may_ change at any time, the public
  > API _should not_ be considered stable, and using a pinned version is _recommended_.

  ## Nodelix configuration

  There is one global configuration for the nodelix application:

  - `:cacerts_path` - the directory to find certificates for
      https connections

  ## Profiles

  You can define multiple nodelix profiles. There is a default empty profile
  which you can configure its args, current directory and environment:

  ```elixir
  config :nodelix,
    default: [
      args: ~w(
        some-script.js
        --some-option
      ),
      cd: Path.expand("../assets", __DIR__),
    ],
    custom: [
      args: ~w(
        another-script.js
        --another-option
      ),
      cd: Path.expand("../assets/scripts", __DIR__),
      env: [
        NODE_DEBUG: "*"
      ]
    ]
  ```

  The default current directory is your project's root.

  To use a profile other than `default`, you can use
  the `--profile` option:

  ```shell
  mix nodelix --profile custom
  ```

  When `mix nodelix` is invoked, the task arguments will
  be appended to the ones configured in the profile.

  """

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

  @doc """
  Returns the configuration for the given profile.

  Raises if the profile does not exist.
  """
  def config_for!(profile) when is_atom(profile) do
    Application.get_env(:nodelix, profile) ||
      raise ArgumentError, """
      Unknown nodelix profile. Make sure the profile is defined in your config/config.exs file, such as:

          config :nodelix,
            #{profile}: [
              args: ~w(
                some-script.js
                --some-option
              ),
              cd: Path.expand("../assets", __DIR__)
            ]
      """
  end

  @doc """
  Runs the given command 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 run(version, profile, extra_args \\ []) when is_atom(profile) and is_list(extra_args) do
    config = config_for!(profile)
    args = (config[:args] || []) ++ extra_args

    if length(args) == 0, do: raise(ArgumentError, "No argument provided.")

    env = Keyword.get(config, :env, %{})

    opts = [
      cd: config[:cd] || File.cwd!(),
      env: env,
      into: IO.stream(:stdio, :line),
      stderr_to_stdout: true
    ]

    VersionManager.bin_path(:node, version)
    |> System.cmd(args, opts)
    |> elem(1)
  end

  @doc """
  Same as `run/3` but using the latest known LTS version at the time of publishing.
  """
  def run_lts(profile, extra_args \\ []) when is_atom(profile) and is_list(extra_args) do
    run(VersionManager.latest_lts_version(), profile, extra_args)
  end

  @doc """
  Installs Node.js if the configured version is not available,
  and then runs `node`.

  Returns the same as `run/3`.
  """
  def install_and_run(version, profile, args) do
    unless VersionManager.is_installed?(version) do
      VersionManager.install(version)
    end

    run(version, profile, args)
  end

  @doc """
  Same as `install_and_run/3` but using the latest known LTS version at the time of publishing.
  """
  def install_and_run_lts(profile, extra_args \\ [])
      when is_atom(profile) and is_list(extra_args) do
    install_and_run(VersionManager.latest_lts_version(), profile, extra_args)
  end
end